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
6 changes: 6 additions & 0 deletions .github/workflows/reusable-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<epoch>` - 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.

Expand Down
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions packages/api/scripts/verify-public-client.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<Set<string>> {
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<string, unknown> };
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<Map<string, string>> {
const found = new Map<string, string>();
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.`);
2 changes: 1 addition & 1 deletion packages/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Loading