Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <org>` passes, and `resources/<org>/` plus
`.vapi-state.<org>.json` exist.
5. Commit `resources/<org>/` and `.vapi-state.<org>.json`. Never commit `.env.<org>`.
5. Commit `resources/<org>/` (including `.vapi-org.json`) and `.vapi-state.<org>.json`. Never commit `.env.<org>`.

If a command stops because the key "belongs to Vapi org …, but resources/<org>/
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 -- <org>`, or ask the human.
Expand Down Expand Up @@ -138,8 +143,8 @@ precisely.
run it locally for the org it names and fix the errors. Without that org's
`.env.<org>`, run `VAPI_PRIVATE_API_KEY=validate-only npm run validate -- <org>`;
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 -- <org>`, or
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<org>` (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/<org>/.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/<org>/` | Yes | Your resources |
| `resources/<org>/` | Yes | Your resources, and `.vapi-org.json`, the org the folder belongs to |
| `.vapi-state.<org>.json` | Yes | Name → UUID mappings for the org |
| `.env.<org>` | **No** | API key and generated binding IDs |
| `.vapi-state-hash/` | No | Your local baseline for detecting dashboard edits |
Expand Down
1 change: 1 addition & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<org>/.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/<org>/.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
Expand Down
54 changes: 53 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -22,6 +30,47 @@ import {

const DRY_RUN_COUNTS = { POST: 0, PATCH: 0, DELETE: 0 };

let orgPinChecked: Promise<void> | undefined;

/**
* Check the key against resources/<org>/.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<void> {
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;
Expand Down Expand Up @@ -75,6 +124,7 @@ export async function vapiRequest<T = VapiResponse>(
endpoint: string,
body: Record<string, unknown>,
): Promise<T> {
await orgPinGuard();
const url = `${VAPI_BASE_URL}${endpoint}`;

if (DRY_RUN) {
Expand Down Expand Up @@ -134,6 +184,7 @@ export async function vapiRequest<T = VapiResponse>(
}

export async function vapiGet<T = unknown>(endpoint: string): Promise<T> {
await orgPinGuard();
const url = `${VAPI_BASE_URL}${endpoint}`;

for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
Expand Down Expand Up @@ -177,6 +228,7 @@ export async function vapiGet<T = unknown>(endpoint: string): Promise<T> {
}

export async function vapiDelete(endpoint: string): Promise<void> {
await orgPinGuard();
const url = `${VAPI_BASE_URL}${endpoint}`;

if (DRY_RUN) {
Expand Down
3 changes: 3 additions & 0 deletions src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,6 +29,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}

async function vapiGet<T>(endpoint: string, debug = false): Promise<T> {
await orgPinGuard();
await sleep(REQUEST_DELAY_MS);
const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, {
headers: {
Expand Down Expand Up @@ -68,6 +70,7 @@ async function vapiGet<T>(endpoint: string, debug = false): Promise<T> {
}

async function vapiDelete(endpoint: string): Promise<void> {
await orgPinGuard();
await sleep(REQUEST_DELAY_MS);
const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, {
method: "DELETE",
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
182 changes: 182 additions & 0 deletions src/org-pin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Pins each org folder to the Vapi org it was set up from.
//
// Nothing else ties resources/<org>/ 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/<org>/.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<unknown>;

/** 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<KeyOrg> {
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/<org>/.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<OrgPinOutcome> {
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" };
}
Loading
Loading