diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index 57fd422..75b7208 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -49,3 +49,9 @@ jobs: out=$(mktemp -d) node_modules/.bin/openapi-ts --file openapi-ts.config.ts -o "$out" rm -rf "$out" + + - name: Verify API client carries only public routes + # The committed client must only reference routes the live public spec + # serves - the internal-surface OpenAPI documents describe routes + # production never exposes, and generating from one would publish them. + run: pnpm --filter @teslemetry/api verify:client diff --git a/AGENTS.md b/AGENTS.md index d6fd813..5798653 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Ships dual ESM/CJS. `src/client/` is generated by `@hey-api/openapi-ts`. **Before assuming a capability gap**: grep `src/client/sdk.gen.ts` first - it usually already has a function for the endpoint, and the real work is a hand-written wrapper method in `TeslemetryVehicleApi.ts`/`TeslemetryEnergyApi.ts`, not a spec regen. -**Codegen**: `@hey-api/openapi-ts` stable releases crash against `typescript@7.x` (they call the `typescript` package's compiler-API enums at runtime, which TS7's native rewrite doesn't expose) - hence the `0.0.0-next-*` pin in `packages/api/package.json`. Move to a stable release once `typescript` has dropped out of a *stable* tag's dependency tree. `openapi-ts.config.ts`'s `input:` fetches the live `api.teslemetry.com/openapi.yaml`, which can be ahead of or behind the api repo's committed `openapi.json`; when regenerating to pick up a specific just-merged api change, fetch that repo's `openapi.json` from `main` instead. CI regenerates into a throwaway temp dir to catch toolchain breaks, and deliberately does not diff against the committed `src/client/` - live-spec drift is expected. +**Codegen**: `@hey-api/openapi-ts` stable releases crash against `typescript@7.x` (they call the `typescript` package's compiler-API enums at runtime, which TS7's native rewrite doesn't expose) - hence the `0.0.0-next-*` pin in `packages/api/package.json`. Move to a stable release once `typescript` has dropped out of a *stable* tag's dependency tree. `openapi-ts.config.ts`'s `input:` fetches the live `api.teslemetry.com/openapi.yaml`, and that is the **only** permitted source: it is the *public* surface, while the api repo's committed `openapi.json` and any dev server's `/openapi.yaml` are the *full internal* surface, so generating from either publishes routes production never serves. When a just-merged api change hasn't reached the live spec yet, wait for the deploy - Cloudflare edge-caches it for 4h, bust with `?cachebust=` - rather than switching source. CI regenerates into a throwaway temp dir to catch toolchain breaks, and deliberately does not diff against the committed `src/client/` - live-spec drift is expected; a separate step (`pnpm --filter @teslemetry/api verify:client`, `packages/api/scripts/verify-public-client.ts`) fails when the committed client references a path the public spec doesn't serve. **Credential leak**: every request carries the access token as a `?token=...` query parameter, so `response.url`/`request.url` on the generated client is credential-bearing. `Teslemetry.ts`'s response interceptor logs only `new URL(response.url).pathname`. Any logging, error-reporting, or telemetry code touching a request/response object here must strip the whole query string (not just a named param) before it reaches a consumer-wired `logger` - consumers forward `debug` logs into user-visible diagnostics. diff --git a/packages/api/package.json b/packages/api/package.json index 0e40eca..9eb1da5 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -22,6 +22,7 @@ "api": "pnpm build && node examples/api.ts", "sse": "pnpm build && node examples/sse.ts", "client": "openapi-ts --file openapi-ts.config.ts", + "verify:client": "tsx scripts/verify-public-client.ts", "build": "tsdown --format cjs,esm", "test": "tsx --test test/*.test.ts", "tsc": "tsc --noEmit", diff --git a/packages/api/scripts/verify-public-client.ts b/packages/api/scripts/verify-public-client.ts new file mode 100644 index 0000000..a2cfd19 --- /dev/null +++ b/packages/api/scripts/verify-public-client.ts @@ -0,0 +1,64 @@ +// The committed client must only reference routes the live public spec serves: +// the internal-surface OpenAPI documents describe routes production never +// exposes, and generating from one would publish them in this package. + +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const CLIENT_FILES = ["src/client/sdk.gen.ts", "src/client/types.gen.ts"]; + +async function specUrl(): Promise { + const config = await readFile(`${packageRoot}openapi-ts.config.ts`, "utf8"); + const input = /input:\s*"([^"]+)"/.exec(config)?.[1]; + if (!input) { + throw new Error("openapi-ts.config.ts has no string `input:` to read the spec URL from"); + } + // The same document openapi-ts generates from, as JSON so this needs no YAML + // parser, and cache-busted because Cloudflare edge-caches the served spec. + return `${input.replace(/\.yaml$/, ".json")}?cachebust=${Date.now()}`; +} + +async function publicPaths(): Promise> { + const url = await specUrl(); + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt++) { + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + const spec = (await response.json()) as { paths?: Record }; + const paths = Object.keys(spec.paths ?? {}); + if (paths.length === 0) throw new Error("spec contains no paths"); + return new Set(paths); + } catch (error) { + lastError = error; + } + } + throw new Error(`could not read ${url}: ${lastError}`); +} + +async function clientPaths(): Promise> { + const found = new Map(); + for (const file of CLIENT_FILES) { + const source = await readFile(`${packageRoot}${file}`, "utf8"); + for (const [, path] of source.matchAll(/url:\s*'([^']+)'/g)) { + if (!found.has(path)) found.set(path, file); + } + } + if (found.size === 0) { + throw new Error(`no url literals found in ${CLIENT_FILES.join(" or ")}`); + } + return found; +} + +const [published, used] = await Promise.all([publicPaths(), clientPaths()]); +const unpublished = [...used].filter(([path]) => !published.has(path)).sort(); + +if (unpublished.length > 0) { + console.error(`${unpublished.length} route(s) in src/client are absent from the public spec:`); + for (const [path, file] of unpublished) console.error(` ${path} (${file})`); + console.error("Regenerate with `pnpm client`, which reads the live public spec."); + process.exit(1); +} + +console.log(`All ${used.size} routes in src/client are served by the public spec.`); diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 47b2b4a..8f04816 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -4,6 +4,6 @@ "outDir": "./dist", "declaration": true }, - "include": ["src/**/*.ts", "test/**/*.ts", "openapi-ts.config.ts"], + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "openapi-ts.config.ts"], "exclude": ["node_modules", "dist", "examples"] }