diff --git a/AGENTS.md b/AGENTS.md index 41666dd..e3ff6a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,7 +110,12 @@ wizard needs a terminal. Instead: Add `--region eu` for EU orgs if auto-detection picks the wrong one. 4. Verify: `npm run validate -- ` passes, and `resources//` plus `.vapi-state..json` exist. -5. Commit `resources//` and `.vapi-state..json`. Never commit `.env.`. +5. Commit `resources//` (including `.vapi-org.json`) and `.vapi-state..json`. Never commit `.env.`. + +If a command stops because the key "belongs to Vapi org …, but resources// +is pinned to org …", the key is for another org. Stop and tell the human. +Never delete or edit `.vapi-org.json` to get past it: that file is the only +thing tying the folder to its org. If setup says the org is "already set up locally", don't delete anything to work around it — run `npm run pull -- `, or ask the human. @@ -138,8 +143,8 @@ precisely. run it locally for the org it names and fix the errors. Without that org's `.env.`, run `VAPI_PRIVATE_API_KEY=validate-only npm run validate -- `; never ask for a real key just to validate. Don't weaken the check or the - workflow to get past it, and never edit the state file or `.vapi-ignore` - to make a reference resolve: fix the name, or pull. + workflow to get past it, and never edit the state file, `.vapi-ignore` or + `.vapi-org.json` to make a reference resolve: fix the name, or pull. 4. **Build PR checks offline** if `vapi-checks.yml` exists: `npm run check -- --all --dry-run`. Fix anything it reports. 5. **Deploy only with a yes** (safety rule 1): `npm run apply -- `, or diff --git a/README.md b/README.md index f601d6a..9984b3d 100644 --- a/README.md +++ b/README.md @@ -182,13 +182,18 @@ To test every pull request automatically, set up [PR checks](docs/guides/pr-chec - **Secrets stay out of git.** API keys live in `.env.` (gitignored) or CI secrets. Credentials are referenced by name and bound to each org's own credentials. +- **Each folder is pinned to its org.** The first command that can see which + org a key belongs to records it in `resources//.vapi-org.json`. After + that, every command checks the key against it before any request, and stops + if the key belongs to a different org, so a wrong key can't pull another + org's resources into the folder or push the folder into another org. - **`apply` is the default deploy.** It pulls first, so it won't overwrite changes made in the dashboard since your last pull. `pull` only syncs down; raw `push` skips the pull and is rarely what you want. | Path | Committed? | What it is | | --- | --- | --- | -| `resources//` | Yes | Your resources | +| `resources//` | Yes | Your resources, and `.vapi-org.json`, the org the folder belongs to | | `.vapi-state..json` | Yes | Name → UUID mappings for the org | | `.env.` | **No** | API key and generated binding IDs | | `.vapi-state-hash/` | No | Your local baseline for detecting dashboard edits | diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index d3edcd1..cffbc5d 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -49,6 +49,7 @@ Set these in GitHub under **Settings → Secrets and variables → Actions**. | --- | --- | | `promotion.yml` | Promotion pipelines between orgs. Start from `promotion.example.yml`. | | `vapi-checks.yml` | PR checks. Start from `vapi-checks.example.yml`. | +| `resources//.vapi-org.json` | The Vapi org ID the folder belongs to. Written by the first command that can see the key's org, then checked by every command before any request: a key for another org stops the command. If a folder genuinely moves to another org, delete the file in a reviewed commit. | | `resources//.vapi-ignore` | Platform resources this repo shouldn't manage, as glob patterns. See `resources/.vapi-ignore.example` and [YAML conventions](../learnings/yaml-conventions.md). | ## Other environment variables diff --git a/src/api.ts b/src/api.ts index c7f0e88..b2b7c7d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,12 @@ -import { DRY_RUN, VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts"; +import { + BASE_DIR, + DRY_RUN, + VAPI_BASE_URL, + VAPI_ENV, + VAPI_TOKEN, + VAPI_TOKEN_SOURCE, +} from "./config.ts"; +import { ORG_PIN_FILE, OrgPinMismatchError, orgPinCheck } from "./org-pin.ts"; import type { VapiResponse } from "./types.ts"; import { userAgentGet } from "./user-agent.ts"; import { @@ -22,6 +30,47 @@ import { const DRY_RUN_COUNTS = { POST: 0, PATCH: 0, DELETE: 0 }; +let orgPinChecked: Promise | undefined; + +/** + * Check the key against resources//.vapi-org.json once per process, + * before the first request (see org-pin.ts). A mismatch exits instead of + * throwing, because several callers catch request errors and retry or carry + * on, and none of them should reach the wrong org. + */ +export function orgPinGuard(): Promise { + orgPinChecked ??= (async () => { + try { + const outcome = await orgPinCheck({ + rootDir: BASE_DIR, + org: VAPI_ENV, + connection: { + token: VAPI_TOKEN ?? "", + baseUrl: VAPI_BASE_URL, + userAgent: userAgentGet(), + }, + keySource: VAPI_TOKEN_SOURCE, + }); + if (outcome.kind === "pinned") + console.log( + `📌 Pinned resources/${VAPI_ENV}/ to Vapi org ${outcome.orgId} (${ORG_PIN_FILE}). Commit it with the folder.`, + ); + if (outcome.kind === "unverified") + console.warn( + `⚠️ Couldn't confirm which Vapi org the key belongs to, so it wasn't checked against resources/${VAPI_ENV}/${ORG_PIN_FILE}.`, + ); + } catch (error) { + if (error instanceof OrgPinMismatchError) { + console.error(`❌ ${error.message}`); + process.exit(1); + } + throw error; + } + })(); + return orgPinChecked; +} + + export function getDryRunCounts(): { POST: number; PATCH: number; @@ -75,6 +124,7 @@ export async function vapiRequest( endpoint: string, body: Record, ): Promise { + await orgPinGuard(); const url = `${VAPI_BASE_URL}${endpoint}`; if (DRY_RUN) { @@ -134,6 +184,7 @@ export async function vapiRequest( } export async function vapiGet(endpoint: string): Promise { + await orgPinGuard(); const url = `${VAPI_BASE_URL}${endpoint}`; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { @@ -177,6 +228,7 @@ export async function vapiGet(endpoint: string): Promise { } export async function vapiDelete(endpoint: string): Promise { + await orgPinGuard(); const url = `${VAPI_BASE_URL}${endpoint}`; if (DRY_RUN) { diff --git a/src/cleanup.ts b/src/cleanup.ts index 1be99cd..79bdbeb 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -7,6 +7,7 @@ import { VAPI_ENV, VAPI_TOKEN, } from "./config.ts"; +import { orgPinGuard } from "./api.ts"; import { FOLDER_MAP } from "./resource-parse.ts"; import { slugify } from "./slug-utils.ts"; import { loadState } from "./state.ts"; @@ -28,6 +29,7 @@ function isRecord(value: unknown): value is Record { } async function vapiGet(endpoint: string, debug = false): Promise { + await orgPinGuard(); await sleep(REQUEST_DELAY_MS); const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, { headers: { @@ -68,6 +70,7 @@ async function vapiGet(endpoint: string, debug = false): Promise { } async function vapiDelete(endpoint: string): Promise { + await orgPinGuard(); await sleep(REQUEST_DELAY_MS); const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, { method: "DELETE", diff --git a/src/config.ts b/src/config.ts index cc20738..bf475f6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -289,6 +289,8 @@ const API_KEY = resolveOrgApiKey(VAPI_ENV, SHELL_ENV, ENV_FILE_VARS); // API configuration export const VAPI_TOKEN = API_KEY?.token; +/** Where VAPI_TOKEN came from, for messages that must never show the key. */ +export const VAPI_TOKEN_SOURCE = API_KEY?.source ?? "the environment"; export const VAPI_BASE_URL = process.env.VAPI_BASE_URL || "https://api.vapi.ai"; if (!VAPI_TOKEN) { diff --git a/src/org-pin.ts b/src/org-pin.ts new file mode 100644 index 0000000..6ed9935 --- /dev/null +++ b/src/org-pin.ts @@ -0,0 +1,182 @@ +// Pins each org folder to the Vapi org it was set up from. +// +// Nothing else ties resources// to an org: the key alone decides where a +// command reads and writes. A key for the wrong org (an exported variable, a +// copied .env file, a mislabelled CI secret) would pull another org's +// resources into the folder, or push this folder into another org. The first +// command that can see the key's org records it in resources//.vapi-org.json, +// which is committed with the folder. Every later command checks the key +// against it and refuses a mismatch before any request. +// +// A private key can't ask which org it belongs to (/org and /org/me reject +// private keys), so the org ID comes from the first resource the key can +// list. Promotion skips dotfiles, so the pin never travels to another org. +// +// Config-free so rollback and tests can check any org. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { orgApiKeyVar } from "./api-key.ts"; +import { + type VapiConnection, + VapiApiError, + vapiFetchJson, +} from "./vapi-client.ts"; + +export const ORG_PIN_FILE = ".vapi-org.json"; + +// Cheap one-item list calls, most likely non-empty first. Every item carries +// the orgId it belongs to. +export const ORG_PROBE_ENDPOINTS = [ + "/assistant", + "/tool", + "/squad", + "/phone-number", + "/credential", + "/structured-output", +]; + +export type OrgProbeFetch = ( + connection: VapiConnection, + endpoint: string, +) => Promise; + +/** What the key's resources say about its org. */ +export type KeyOrg = + | { kind: "found"; orgId: string } + // Every probe answered and none listed anything: the key's org is empty. + | { kind: "empty" } + // A probe failed (network, 5xx, 401), so the org can't be told. + | { kind: "unknown" }; + +export type OrgPinOutcome = + | { kind: "match" } + | { kind: "pinned"; orgId: string } + | { kind: "unverified" } + | { kind: "unpinned" }; + +export class OrgPinMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = "OrgPinMismatchError"; + } +} + +// A probe is a safety check, not the command: don't spend a minute of +// backoff on a flaky endpoint before the real requests start. +const probeFetchDefault: OrgProbeFetch = (connection, endpoint) => + vapiFetchJson(connection, "GET", endpoint, undefined, { + retry: "rate-limit-only", + maxRetries: 2, + }); + +export function orgPinPath(rootDir: string, org: string): string { + return join(rootDir, "resources", org, ORG_PIN_FILE); +} + +/** The org ID this folder is pinned to, if any. A corrupt pin file throws. */ +export function orgPinRead(rootDir: string, org: string): string | undefined { + const path = orgPinPath(rootDir, org); + if (!existsSync(path)) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Can't read resources/${org}/${ORG_PIN_FILE}: ${reason}. Restore it from git.`, + ); + } + const orgId = (parsed as { orgId?: unknown } | null)?.orgId; + return typeof orgId === "string" && orgId ? orgId : undefined; +} + +function itemsOf(data: unknown): unknown[] { + if (Array.isArray(data)) return data; + const results = (data as { results?: unknown } | null)?.results; + return Array.isArray(results) ? results : []; +} + +/** Find the key's org from the first resource it can list. */ +export async function keyOrgFetch( + connection: VapiConnection, + probeFetch: OrgProbeFetch = probeFetchDefault, +): Promise { + let failed = false; + for (const endpoint of ORG_PROBE_ENDPOINTS) { + let data: unknown; + try { + data = await probeFetch(connection, `${endpoint}?limit=1`); + } catch (error) { + // A rejected key fails every probe; the command reports it properly. + if (error instanceof VapiApiError && error.statusCode === 401) + return { kind: "unknown" }; + failed = true; + continue; + } + for (const item of itemsOf(data)) { + const orgId = (item as { orgId?: unknown } | null)?.orgId; + if (typeof orgId === "string" && orgId) return { kind: "found", orgId }; + } + } + return failed ? { kind: "unknown" } : { kind: "empty" }; +} + +export interface OrgPinCheckArgs { + rootDir: string; + /** The org slug: the folder under resources/. */ + org: string; + connection: VapiConnection; + /** Where the key came from, for the error message (never the key). */ + keySource: string; + probeFetch?: OrgProbeFetch; +} + +/** + * Check the key against resources//.vapi-org.json. Pins an unpinned + * folder to the key's org on first sight. Throws OrgPinMismatchError when the + * key belongs to another org, or to an empty org while the folder is pinned + * to one that had resources. + */ +export async function orgPinCheck(args: OrgPinCheckArgs): Promise { + const { rootDir, org, connection, keySource } = args; + // Set only by tests/no-vapi-api.ts: tests that stub fetch count requests. + if (process.env.VAPI_GITOPS_ORG_PIN === "off") return { kind: "unpinned" }; + const pinned = orgPinRead(rootDir, org); + // No folder and no pin: nothing to protect or record (a CI run org, or an + // org whose first pull hasn't created the folder yet). Skip the probes. + const folderExists = existsSync(join(rootDir, "resources", org)); + if (!pinned && !folderExists) return { kind: "unpinned" }; + const keyOrg = await keyOrgFetch(connection, args.probeFetch); + const folder = `resources/${org}/`; + const fix = + ` Use ${org}'s own key, for example by exporting ${orgApiKeyVar(org)}.\n` + + ` If this folder really moved to another org, delete ${folder}${ORG_PIN_FILE}\n` + + ` in a reviewed commit and run the command again.`; + + if (keyOrg.kind === "found") { + if (pinned === keyOrg.orgId) return { kind: "match" }; + if (pinned) + throw new OrgPinMismatchError( + `The API key from ${keySource} belongs to Vapi org ${keyOrg.orgId}, but ${folder} is pinned to org ${pinned} (${ORG_PIN_FILE}).\n` + + ` Stopped before reading or changing anything.\n${fix}`, + ); + if (!folderExists) return { kind: "unpinned" }; + writeFileSync( + orgPinPath(rootDir, org), + `${JSON.stringify({ orgId: keyOrg.orgId }, null, 2)}\n`, + ); + return { kind: "pinned", orgId: keyOrg.orgId }; + } + + if (!pinned) return { kind: "unpinned" }; + // The folder was pinned from an org that listed resources. A key whose org + // lists none is far more likely a fresh, different org than the same org + // emptied out, and pushing would fill it with this folder's resources. + if (keyOrg.kind === "empty") + throw new OrgPinMismatchError( + `The API key from ${keySource} belongs to an org with no resources, but ${folder} is pinned to org ${pinned} (${ORG_PIN_FILE}).\n` + + ` Stopped before reading or changing anything.\n${fix}`, + ); + return { kind: "unverified" }; +} diff --git a/src/rollback-cmd.ts b/src/rollback-cmd.ts index 544c3e7..bcd0864 100644 --- a/src/rollback-cmd.ts +++ b/src/rollback-cmd.ts @@ -12,6 +12,7 @@ import { missingApiKeyMessage, resolveOrgApiKey } from "./api-key.ts"; import { existsSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; +import { ORG_PIN_FILE, OrgPinMismatchError, orgPinCheck } from "./org-pin.ts"; import { listSnapshotTimestamps, loadSnapshot } from "./snapshot.ts"; import { userAgentGet } from "./user-agent.ts"; @@ -22,6 +23,8 @@ interface RollbackEnv { env: string; token: string; baseUrl: string; + /** Where the key came from, for messages. */ + source: string; } function loadEnvFile(env: string): RollbackEnv { @@ -58,7 +61,7 @@ function loadEnvFile(env: string): RollbackEnv { console.error(`❌ ${missingApiKeyMessage(env)}`); process.exit(1); } - return { env, token, baseUrl }; + return { env, token, baseUrl, source: resolved?.source ?? "the environment" }; } function printUsage(): void { @@ -138,6 +141,24 @@ async function main(): Promise { } const cfg = loadEnvFile(args.env); + // Rollback PATCHes resources by the UUIDs in this org's state, so a key for + // another org must stop here (see org-pin.ts). + try { + const outcome = await orgPinCheck({ + rootDir: BASE_DIR, + org: args.env, + connection: { token: cfg.token, baseUrl: cfg.baseUrl, userAgent: userAgentGet() }, + keySource: cfg.source, + }); + if (outcome.kind === "unverified") + console.warn( + `⚠️ Couldn't confirm which Vapi org the key belongs to, so it wasn't checked against resources/${args.env}/${ORG_PIN_FILE}.`, + ); + } catch (error) { + if (!(error instanceof OrgPinMismatchError)) throw error; + console.error(`❌ ${error.message}`); + process.exit(1); + } const entries = await loadSnapshot(BASE_DIR, args.env, args.to!); if (entries.length === 0) { console.log("Snapshot directory exists but contains no resources."); diff --git a/tests/no-vapi-api.ts b/tests/no-vapi-api.ts index 4a74f5d..29d0955 100644 --- a/tests/no-vapi-api.ts +++ b/tests/no-vapi-api.ts @@ -4,6 +4,9 @@ process.env.VAPI_BASE_URL = "http://127.0.0.1:9"; delete process.env.VAPI_PRIVATE_API_KEY; delete process.env.VAPI_TOKEN; +// Tests that stub fetch count requests; the org pin check's probes would add +// to them. tests/org-pin.test.ts turns it back on for itself. +process.env.VAPI_GITOPS_ORG_PIN = "off"; // Per-org keys (VAPI_PRIVATE_API_KEY_) a developer exports in their shell. for (const name of Object.keys(process.env)) if (name.startsWith("VAPI_PRIVATE_API_KEY_")) delete process.env[name]; diff --git a/tests/org-pin.test.ts b/tests/org-pin.test.ts new file mode 100644 index 0000000..3f67f82 --- /dev/null +++ b/tests/org-pin.test.ts @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + keyOrgFetch, + ORG_PIN_FILE, + OrgPinMismatchError, + type OrgProbeFetch, + orgPinCheck, + orgPinRead, +} from "../src/org-pin.ts"; +import { VapiApiError } from "../src/vapi-client.ts"; + +// Nothing used to tie resources// to a Vapi org, so a key for the wrong +// org read and wrote that org without a word. The folder now records its org +// in .vapi-org.json, and every command checks the key against it first. + +// tests/no-vapi-api.ts turns the check off for the rest of the suite. +delete process.env.VAPI_GITOPS_ORG_PIN; + +const REPO = fileURLToPath(new URL("..", import.meta.url)); +const CONNECTION = { token: "test-token-not-used", baseUrl: "http://stub", userAgent: "test" }; +const SOURCE = "the VAPI_PRIVATE_API_KEY_ACME environment variable"; + +// A probe stub: endpoint (without the query) → response or error. +function probes(responses: Record): { + probeFetch: OrgProbeFetch; + seen: string[]; +} { + const seen: string[] = []; + const probeFetch: OrgProbeFetch = async (_connection, endpoint) => { + seen.push(endpoint); + const response = responses[endpoint.split("?")[0]!] ?? []; + if (response instanceof Error) throw response; + return response; + }; + return { probeFetch, seen }; +} + +function orgDir(options: { folder: boolean; pin?: string }): string { + const root = mkdtempSync(join(tmpdir(), "org-pin-")); + if (options.folder || options.pin !== undefined) + mkdirSync(join(root, "resources", "acme"), { recursive: true }); + if (options.pin !== undefined) + writeFileSync(join(root, "resources", "acme", ORG_PIN_FILE), options.pin); + return root; +} + +const serverError = new VapiApiError("GET", "/tool", 503, "unavailable", ""); +const unauthorized = new VapiApiError("GET", "/assistant", 401, "Unauthorized", ""); + +// ── Finding the key's org ──────────────────────────────────────────────── + +test("keyOrgFetch reads orgId from the first resource listed", async () => { + const { probeFetch, seen } = probes({ "/assistant": [{ id: "a1", orgId: "org-a" }] }); + assert.deepEqual(await keyOrgFetch(CONNECTION, probeFetch), { kind: "found", orgId: "org-a" }); + assert.deepEqual(seen, ["/assistant?limit=1"]); +}); + +test("keyOrgFetch moves on from empty lists and reads { results } pages", async () => { + const { probeFetch, seen } = probes({ + "/structured-output": { results: [{ id: "s1", orgId: "org-a" }] }, + }); + assert.deepEqual(await keyOrgFetch(CONNECTION, probeFetch), { kind: "found", orgId: "org-a" }); + assert.equal(seen.length, 6); +}); + +test("keyOrgFetch reports an org that lists nothing as empty", async () => { + assert.deepEqual(await keyOrgFetch(CONNECTION, probes({}).probeFetch), { kind: "empty" }); +}); + +test("keyOrgFetch reports unknown when a probe fails and none finds an org", async () => { + assert.deepEqual( + await keyOrgFetch(CONNECTION, probes({ "/tool": serverError }).probeFetch), + { kind: "unknown" }, + ); +}); + +test("keyOrgFetch stops at a rejected key", async () => { + const { probeFetch, seen } = probes({ "/assistant": unauthorized }); + assert.deepEqual(await keyOrgFetch(CONNECTION, probeFetch), { kind: "unknown" }); + assert.deepEqual(seen, ["/assistant?limit=1"]); +}); + +// ── Checking against the pin ───────────────────────────────────────────── + +test("orgPinCheck pins an unpinned folder to the key's org", async () => { + const root = orgDir({ folder: true }); + try { + const { probeFetch } = probes({ "/assistant": [{ orgId: "org-a" }] }); + const outcome = await orgPinCheck({ rootDir: root, org: "acme", connection: CONNECTION, keySource: SOURCE, probeFetch }); + assert.deepEqual(outcome, { kind: "pinned", orgId: "org-a" }); + assert.equal( + readFileSync(join(root, "resources", "acme", ORG_PIN_FILE), "utf8"), + '{\n "orgId": "org-a"\n}\n', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("orgPinCheck makes no request for an org with no folder and no pin", async () => { + const root = orgDir({ folder: false }); + try { + const { probeFetch, seen } = probes({ "/assistant": [{ orgId: "org-a" }] }); + const outcome = await orgPinCheck({ rootDir: root, org: "acme", connection: CONNECTION, keySource: SOURCE, probeFetch }); + assert.deepEqual(outcome, { kind: "unpinned" }); + assert.deepEqual(seen, []); + assert.equal(existsSync(join(root, "resources")), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("orgPinCheck passes a key for the pinned org", async () => { + const root = orgDir({ folder: true, pin: '{"orgId":"org-a"}' }); + try { + const { probeFetch } = probes({ "/assistant": [{ orgId: "org-a" }] }); + assert.deepEqual( + await orgPinCheck({ rootDir: root, org: "acme", connection: CONNECTION, keySource: SOURCE, probeFetch }), + { kind: "match" }, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("orgPinCheck refuses a key for another org, naming both orgs and the source", async () => { + const root = orgDir({ folder: true, pin: '{"orgId":"org-a"}' }); + try { + const { probeFetch } = probes({ "/assistant": [{ orgId: "org-b" }] }); + await assert.rejects( + orgPinCheck({ rootDir: root, org: "acme", connection: CONNECTION, keySource: SOURCE, probeFetch }), + (error: Error) => { + assert.ok(error instanceof OrgPinMismatchError); + assert.match(error.message, /belongs to Vapi org org-b/); + assert.match(error.message, /pinned to org org-a/); + assert.match(error.message, /VAPI_PRIVATE_API_KEY_ACME environment variable/); + assert.doesNotMatch(error.message, /test-token-not-used/); + return true; + }, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("orgPinCheck refuses a key whose org is empty when the folder is pinned", async () => { + const root = orgDir({ folder: true, pin: '{"orgId":"org-a"}' }); + try { + await assert.rejects( + orgPinCheck({ rootDir: root, org: "acme", connection: CONNECTION, keySource: SOURCE, probeFetch: probes({}).probeFetch }), + /belongs to an org with no resources/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("orgPinCheck can't verify when the probes fail, and says so", async () => { + const root = orgDir({ folder: true, pin: '{"orgId":"org-a"}' }); + try { + const { probeFetch } = probes({ "/assistant": serverError }); + assert.deepEqual( + await orgPinCheck({ rootDir: root, org: "acme", connection: CONNECTION, keySource: SOURCE, probeFetch }), + { kind: "unverified" }, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("orgPinRead names the file when the pin is corrupt", () => { + const root = orgDir({ folder: true, pin: "{not json" }); + try { + assert.throws(() => orgPinRead(root, "acme"), /resources\/acme\/\.vapi-org\.json/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// ── A real command ─────────────────────────────────────────────────────── + +test("pull stops before listing anything when the key belongs to another org", async () => { + const seen: string[] = []; + const server = createServer((req, res) => { + seen.push(req.url ?? ""); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify([{ id: "a1", orgId: "org-b", name: "x" }])); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const dir = mkdtempSync(join(tmpdir(), "org-pin-cli-")); + try { + cpSync(join(REPO, "src"), join(dir, "src"), { recursive: true }); + cpSync(join(REPO, "package.json"), join(dir, "package.json")); + symlinkSync(join(REPO, "node_modules"), join(dir, "node_modules"), "dir"); + mkdirSync(join(dir, "resources", "acme"), { recursive: true }); + writeFileSync(join(dir, "resources", "acme", ORG_PIN_FILE), '{"orgId":"org-a"}\n'); + writeFileSync(join(dir, ".env.acme"), "VAPI_PRIVATE_API_KEY=wrong-org-key\n"); + const env: NodeJS.ProcessEnv = { ...process.env, VAPI_BASE_URL: `http://127.0.0.1:${port}` }; + delete env.VAPI_GITOPS_ORG_PIN; + // Async spawn: the stub server runs in this process. + const result = await new Promise<{ code: number | null; output: string }>((resolve) => { + const child = spawn(process.execPath, ["--import", "tsx", "src/pull.ts", "acme"], { cwd: dir, env }); + let output = ""; + child.stdout.on("data", (chunk) => (output += chunk)); + child.stderr.on("data", (chunk) => (output += chunk)); + child.on("close", (code) => resolve({ code, output })); + }); + assert.equal(result.code, 1); + assert.match(result.output, /belongs to Vapi org org-b, but resources\/acme\/ is pinned to org org-a/); + assert.doesNotMatch(result.output, /wrong-org-key/); + // Only the probe went out: no listing, no writes. + assert.deepEqual(seen, ["/assistant?limit=1"]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } +});