Files
Alberto Schiabel 8a19b75915 fix(docs): publish production API URLs and guard against staging leaks (#3770)
## What was wrong

The API reference docs were shipping staging URLs to users. There were
two separate leaks, both from the same source: the scheduled
`docs-update-data` workflow fetches the OpenAPI specs and toolkit data
from staging every 5 hours and auto-commits them.

The first leak was the curl base URL. `servers[0].url` in `openapi.json`
and `openapi-v3.json` rendered as `http://staging-apollo.composio.dev`
in every curl example. That is what
https://github.com/ComposioHQ/composio/pull/3761 tried to fix.

The second leak was in the toolkit data. `toolkits.json` carried 269
`https://staging-backend.composio.dev/api/v1/auth-apps/add` default
values, surfaced in the white-labeling and auth-config docs. #3761 never
touched this file.

## Which PR caused it

Karan asked on Slack whether we could pin down the PR that introduced
this. We can. It was https://github.com/ComposioHQ/composio/pull/3426
(commit `a3e58a421`, merged 2026-07-03), which flipped `servers[0].url`
from `https://backend.composio.dev` / `PRODUCTION API` to
`http://staging-apollo.composio.dev` / `STAGING API` in both specs.

It was not a hand-written change. #3426 is itself an auto-generated PR
from this same `docs-update-data` workflow, opened by
`github-actions[bot]` on the `docs/auto-update-data` branch. The
workflow fetched from staging, staging serves the staging server URL in
its spec, and the value landed in the committed files where nobody
caught it inside a large auto-generated diff. That is the reason the
real fix belongs in the generator and the workflow, not in a one-off
edit to the JSON.

## Why #3761 was not enough

#3761 pinned `spec.servers` to production inside `fetch-openapi.mjs`.
That closed the first leak, but three things stayed open.

It only covered the OpenAPI specs. The 269 staging hosts in
`toolkits.json` come from a different generator, `generate-toolkits.ts`,
and stayed live on the docs site.

It added no CI guard. The fix was a single line in a generator with
nothing asserting it. A later refactor that dropped or reordered that
line would republish staging on the next 5-hour regeneration, which is
precisely the recurring failure that produced the original report.

And it scrubbed the symptom rather than the source. The workflow kept
fetching from staging; the pin just rewrote one field afterward.

## The fix

I addressed it at four layers, so no single regression brings staging
back.

**Source.** `docs-update-data.yml` no longer overrides the base URL to
staging. The generators default to production, so the docs reflect
production.

**Generators.** The production URL now lives in one place,
`docs/scripts/production-api.mjs`. The toolkit and meta-tools generators
sanitize any staging host to production before writing, so a staging
fetch can no longer republish staging.

**Committed data.** I rewrote the 269 staging hosts already in
`toolkits.json` to production.

**Guard.** `docs/tests/static/production-urls.test.ts` asserts the
OpenAPI `servers` is production and scans both specs and every
`public/data/*.json` for any `staging-*.composio.dev` host. It is an
independent oracle: it hardcodes the expected value and deliberately
does not import the generator constants, so a wrong edit there fails CI
instead of moving both sides together. I verified it catches both the
original `staging-apollo` and the `staging-backend` leaks.

## One thing to confirm before merge

`COMPOSIO_API_KEY` is paired with `COMPOSIO_BASE_URL_STAGING` in every
other workflow, so it is most likely a staging key. If that is the case,
provision a production-capable key before the next scheduled run.
Otherwise that run fails on auth, which is loud and safe, rather than
silently republishing staging. The generator sanitizer and the guard
keep the output correct regardless of which environment the fetch hits,
so there is no risk in the interim.

## Testing

- `bun test tests/static/` passes 21 of 21 (16 existing, 5 new).
- The shared module loads and all three generators build under bun.
- No `staging-*.composio.dev` host remains under `docs/public/`.
- `docs-update-data.yml` re-validated as valid YAML.
2026-07-13 13:19:55 +04:00

68 lines
2.6 KiB
TypeScript

/**
* Production-URL guard for published docs artifacts.
*
* The "Docs - Update Data" workflow fetches the OpenAPI specs and toolkit data
* from STAGING every ~5h and auto-commits the regenerated files. Staging hosts
* have leaked into the committed docs before (the API-reference curl base URL
* pointed at `http://staging-apollo.composio.dev`), so this test fails CI on any
* regeneration PR that ships a non-production host.
*
* This is an independent oracle: the expected production URL and the banned
* staging hosts are stated here directly and are intentionally NOT imported from
* scripts/production-api.mjs. A wrong edit to that generator constant must make
* this test fail, not move the expectation with it.
*/
import { describe, test, expect } from "bun:test";
import { readFile, readdir } from "fs/promises";
import { join } from "path";
const PUBLIC_DIR = join(import.meta.dir, "../../public");
const DATA_DIR = join(PUBLIC_DIR, "data");
const EXPECTED_SERVER = {
url: "https://backend.composio.dev",
description: "PRODUCTION API",
};
// Any non-production Composio host must never appear in published data.
const STAGING_HOST_RE = /staging-[a-z0-9-]*\.composio\.dev/gi;
const OPENAPI_SPECS = ["openapi.json", "openapi-v3.json"];
/** Read a file and return the distinct staging hosts it mentions, if any. */
async function findStagingHosts(absPath: string): Promise<string[]> {
const contents = await readFile(absPath, "utf-8");
const matches = contents.match(STAGING_HOST_RE) ?? [];
const distinctHosts = [...new Set(matches)];
return distinctHosts;
}
describe("OpenAPI specs - production server", () => {
test.each(OPENAPI_SPECS)("%s servers[0] is the production API", async (spec) => {
const raw = await readFile(join(PUBLIC_DIR, spec), "utf-8");
const { servers } = JSON.parse(raw);
expect(servers).toEqual([EXPECTED_SERVER]);
});
});
describe("Published docs data - no staging hosts", () => {
test.each(OPENAPI_SPECS)("%s has no staging host", async (spec) => {
const stagingHosts = await findStagingHosts(join(PUBLIC_DIR, spec));
expect(stagingHosts).toEqual([]);
});
test("every public/data/*.json has no staging host", async () => {
const jsonFiles = (await readdir(DATA_DIR)).filter((file) => file.endsWith(".json"));
const offenders: string[] = [];
for (const file of jsonFiles) {
const stagingHosts = await findStagingHosts(join(DATA_DIR, file));
if (stagingHosts.length > 0) {
offenders.push(`${file}: ${stagingHosts.join(", ")}`);
}
}
expect(offenders).toEqual([]);
});
});