|
| 1 | +// Pins each org folder to the Vapi org it was set up from. |
| 2 | +// |
| 3 | +// Nothing else ties resources/<org>/ to an org: the key alone decides where a |
| 4 | +// command reads and writes. A key for the wrong org (an exported variable, a |
| 5 | +// copied .env file, a mislabelled CI secret) would pull another org's |
| 6 | +// resources into the folder, or push this folder into another org. The first |
| 7 | +// command that can see the key's org records it in resources/<org>/.vapi-org.json, |
| 8 | +// which is committed with the folder. Every later command checks the key |
| 9 | +// against it and refuses a mismatch before any request. |
| 10 | +// |
| 11 | +// A private key can't ask which org it belongs to (/org and /org/me reject |
| 12 | +// private keys), so the org ID comes from the first resource the key can |
| 13 | +// list. Promotion skips dotfiles, so the pin never travels to another org. |
| 14 | +// |
| 15 | +// Config-free so rollback and tests can check any org. |
| 16 | + |
| 17 | +import { existsSync, readFileSync, writeFileSync } from "node:fs"; |
| 18 | +import { join } from "node:path"; |
| 19 | +import { orgApiKeyVar } from "./api-key.ts"; |
| 20 | +import { |
| 21 | + type VapiConnection, |
| 22 | + VapiApiError, |
| 23 | + vapiFetchJson, |
| 24 | +} from "./vapi-client.ts"; |
| 25 | + |
| 26 | +export const ORG_PIN_FILE = ".vapi-org.json"; |
| 27 | + |
| 28 | +// Cheap one-item list calls, most likely non-empty first. Every item carries |
| 29 | +// the orgId it belongs to. |
| 30 | +export const ORG_PROBE_ENDPOINTS = [ |
| 31 | + "/assistant", |
| 32 | + "/tool", |
| 33 | + "/squad", |
| 34 | + "/phone-number", |
| 35 | + "/credential", |
| 36 | + "/structured-output", |
| 37 | +]; |
| 38 | + |
| 39 | +export type OrgProbeFetch = ( |
| 40 | + connection: VapiConnection, |
| 41 | + endpoint: string, |
| 42 | +) => Promise<unknown>; |
| 43 | + |
| 44 | +/** What the key's resources say about its org. */ |
| 45 | +export type KeyOrg = |
| 46 | + | { kind: "found"; orgId: string } |
| 47 | + // Every probe answered and none listed anything: the key's org is empty. |
| 48 | + | { kind: "empty" } |
| 49 | + // A probe failed (network, 5xx, 401), so the org can't be told. |
| 50 | + | { kind: "unknown" }; |
| 51 | + |
| 52 | +export type OrgPinOutcome = |
| 53 | + | { kind: "match" } |
| 54 | + | { kind: "pinned"; orgId: string } |
| 55 | + | { kind: "unverified" } |
| 56 | + | { kind: "unpinned" }; |
| 57 | + |
| 58 | +export class OrgPinMismatchError extends Error { |
| 59 | + constructor(message: string) { |
| 60 | + super(message); |
| 61 | + this.name = "OrgPinMismatchError"; |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +// A probe is a safety check, not the command: don't spend a minute of |
| 66 | +// backoff on a flaky endpoint before the real requests start. |
| 67 | +const probeFetchDefault: OrgProbeFetch = (connection, endpoint) => |
| 68 | + vapiFetchJson(connection, "GET", endpoint, undefined, { |
| 69 | + retry: "rate-limit-only", |
| 70 | + maxRetries: 2, |
| 71 | + }); |
| 72 | + |
| 73 | +export function orgPinPath(rootDir: string, org: string): string { |
| 74 | + return join(rootDir, "resources", org, ORG_PIN_FILE); |
| 75 | +} |
| 76 | + |
| 77 | +/** The org ID this folder is pinned to, if any. A corrupt pin file throws. */ |
| 78 | +export function orgPinRead(rootDir: string, org: string): string | undefined { |
| 79 | + const path = orgPinPath(rootDir, org); |
| 80 | + if (!existsSync(path)) return undefined; |
| 81 | + let parsed: unknown; |
| 82 | + try { |
| 83 | + parsed = JSON.parse(readFileSync(path, "utf8")); |
| 84 | + } catch (error) { |
| 85 | + const reason = error instanceof Error ? error.message : String(error); |
| 86 | + throw new Error( |
| 87 | + `Can't read resources/${org}/${ORG_PIN_FILE}: ${reason}. Restore it from git.`, |
| 88 | + ); |
| 89 | + } |
| 90 | + const orgId = (parsed as { orgId?: unknown } | null)?.orgId; |
| 91 | + return typeof orgId === "string" && orgId ? orgId : undefined; |
| 92 | +} |
| 93 | + |
| 94 | +function itemsOf(data: unknown): unknown[] { |
| 95 | + if (Array.isArray(data)) return data; |
| 96 | + const results = (data as { results?: unknown } | null)?.results; |
| 97 | + return Array.isArray(results) ? results : []; |
| 98 | +} |
| 99 | + |
| 100 | +/** Find the key's org from the first resource it can list. */ |
| 101 | +export async function keyOrgFetch( |
| 102 | + connection: VapiConnection, |
| 103 | + probeFetch: OrgProbeFetch = probeFetchDefault, |
| 104 | +): Promise<KeyOrg> { |
| 105 | + let failed = false; |
| 106 | + for (const endpoint of ORG_PROBE_ENDPOINTS) { |
| 107 | + let data: unknown; |
| 108 | + try { |
| 109 | + data = await probeFetch(connection, `${endpoint}?limit=1`); |
| 110 | + } catch (error) { |
| 111 | + // A rejected key fails every probe; the command reports it properly. |
| 112 | + if (error instanceof VapiApiError && error.statusCode === 401) |
| 113 | + return { kind: "unknown" }; |
| 114 | + failed = true; |
| 115 | + continue; |
| 116 | + } |
| 117 | + for (const item of itemsOf(data)) { |
| 118 | + const orgId = (item as { orgId?: unknown } | null)?.orgId; |
| 119 | + if (typeof orgId === "string" && orgId) return { kind: "found", orgId }; |
| 120 | + } |
| 121 | + } |
| 122 | + return failed ? { kind: "unknown" } : { kind: "empty" }; |
| 123 | +} |
| 124 | + |
| 125 | +export interface OrgPinCheckArgs { |
| 126 | + rootDir: string; |
| 127 | + /** The org slug: the folder under resources/. */ |
| 128 | + org: string; |
| 129 | + connection: VapiConnection; |
| 130 | + /** Where the key came from, for the error message (never the key). */ |
| 131 | + keySource: string; |
| 132 | + probeFetch?: OrgProbeFetch; |
| 133 | +} |
| 134 | + |
| 135 | +/** |
| 136 | + * Check the key against resources/<org>/.vapi-org.json. Pins an unpinned |
| 137 | + * folder to the key's org on first sight. Throws OrgPinMismatchError when the |
| 138 | + * key belongs to another org, or to an empty org while the folder is pinned |
| 139 | + * to one that had resources. |
| 140 | + */ |
| 141 | +export async function orgPinCheck(args: OrgPinCheckArgs): Promise<OrgPinOutcome> { |
| 142 | + const { rootDir, org, connection, keySource } = args; |
| 143 | + // Set only by tests/no-vapi-api.ts: tests that stub fetch count requests. |
| 144 | + if (process.env.VAPI_GITOPS_ORG_PIN === "off") return { kind: "unpinned" }; |
| 145 | + const pinned = orgPinRead(rootDir, org); |
| 146 | + // No folder and no pin: nothing to protect or record (a CI run org, or an |
| 147 | + // org whose first pull hasn't created the folder yet). Skip the probes. |
| 148 | + const folderExists = existsSync(join(rootDir, "resources", org)); |
| 149 | + if (!pinned && !folderExists) return { kind: "unpinned" }; |
| 150 | + const keyOrg = await keyOrgFetch(connection, args.probeFetch); |
| 151 | + const folder = `resources/${org}/`; |
| 152 | + const fix = |
| 153 | + ` Use ${org}'s own key, for example by exporting ${orgApiKeyVar(org)}.\n` + |
| 154 | + ` If this folder really moved to another org, delete ${folder}${ORG_PIN_FILE}\n` + |
| 155 | + ` in a reviewed commit and run the command again.`; |
| 156 | + |
| 157 | + if (keyOrg.kind === "found") { |
| 158 | + if (pinned === keyOrg.orgId) return { kind: "match" }; |
| 159 | + if (pinned) |
| 160 | + throw new OrgPinMismatchError( |
| 161 | + `The API key from ${keySource} belongs to Vapi org ${keyOrg.orgId}, but ${folder} is pinned to org ${pinned} (${ORG_PIN_FILE}).\n` + |
| 162 | + ` Stopped before reading or changing anything.\n${fix}`, |
| 163 | + ); |
| 164 | + if (!folderExists) return { kind: "unpinned" }; |
| 165 | + writeFileSync( |
| 166 | + orgPinPath(rootDir, org), |
| 167 | + `${JSON.stringify({ orgId: keyOrg.orgId }, null, 2)}\n`, |
| 168 | + ); |
| 169 | + return { kind: "pinned", orgId: keyOrg.orgId }; |
| 170 | + } |
| 171 | + |
| 172 | + if (!pinned) return { kind: "unpinned" }; |
| 173 | + // The folder was pinned from an org that listed resources. A key whose org |
| 174 | + // lists none is far more likely a fresh, different org than the same org |
| 175 | + // emptied out, and pushing would fill it with this folder's resources. |
| 176 | + if (keyOrg.kind === "empty") |
| 177 | + throw new OrgPinMismatchError( |
| 178 | + `The API key from ${keySource} belongs to an org with no resources, but ${folder} is pinned to org ${pinned} (${ORG_PIN_FILE}).\n` + |
| 179 | + ` Stopped before reading or changing anything.\n${fix}`, |
| 180 | + ); |
| 181 | + return { kind: "unverified" }; |
| 182 | +} |
0 commit comments