Skip to content

Commit e8450af

Browse files
committed
feat(api): drive the route-spec ratchet to zero and delete the baseline
The published document described 151 of the 244 operations createApp() actually serves. It now describes all 244, and src/openapi/unspecced-routes-baseline.json is gone -- there is no longer anywhere to record an exception. The 93 newly-described routes include the entire ORB management surface (/v1/orb/*, /v1/internal/orb/*), the fleet kill-switch and config-push, the per-repo BYO-key routes, and the 32 internal job endpoints. Each carries a stable operationId, real tags, and an auth level that DERIVES its security stanza rather than having one bolted on afterwards by path prefix. Three fixes the work forced: - RouteAuth gained 'orb' and 'webhook'. The ORB ingress genuinely does not authenticate the way the rest of the API does, and the old model published it as needing no credential at all. It needs a different one: an ORB-issued bearer for the relay and token endpoints, an HMAC signature header for the webhook. Both now have their own security scheme. - registerRouteSpec never emitted path parameters, so every templated segment it registered was a Cloudflare 30046 schema-validation warning and a hole in any generated client. Derived from the path now, because there is no case where a path parameter is optional. - test/unit/openapi.test.ts asserted /v1/internal/jobs/generate-signal-snapshots /run must be ABSENT from the document as a removed route. It is live. The assertion was requiring the document to stay wrong. The ratchet's other direction earned its keep immediately: it rejected two job routes I had assumed existed, because two of the internal jobs are operator-triggered repairs with a /run form and no enqueue sibling.
1 parent 5111e92 commit e8450af

8 files changed

Lines changed: 3713 additions & 160 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 2939 additions & 31 deletions
Large diffs are not rendered by default.

src/openapi/define-route.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { z } from "zod";
2121
* declaration the runtime gate enforces -- replacing `isProtectedPath()`, a second, path-prefix
2222
* model of the same policy that had already drifted out of agreement with it.
2323
*/
24-
export type RouteAuth = "public" | "token" | "session" | "internal";
24+
export type RouteAuth = "public" | "token" | "session" | "internal" | "orb" | "webhook";
2525

2626
export type RouteMethod = "get" | "post" | "put" | "patch" | "delete";
2727

@@ -46,16 +46,40 @@ export type DefineRouteOptions<Body extends z.ZodTypeAny | undefined, Query exte
4646
responses: Record<number, { description: string; schema?: z.ZodTypeAny }>;
4747
};
4848

49+
/**
50+
* Every `:param` in the path, as an OpenAPI `in: path` parameter schema.
51+
*
52+
* Emitted automatically rather than declared per route: a templated segment with no matching
53+
* parameter is a schema-validation warning (Cloudflare 30046) and leaves a generated client with a
54+
* URL it cannot fill, and there is no case where a path parameter is optional -- so the correct
55+
* declaration is fully derivable from the path itself and nothing is gained by asking for it twice.
56+
*/
57+
function pathParameters(path: string): { params: z.ZodObject } | undefined {
58+
const names = [...path.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1]!);
59+
if (names.length === 0) return undefined;
60+
return { params: z.object(Object.fromEntries(names.map((name) => [name, z.string()]))) };
61+
}
62+
4963
/** Hono writes `:param`; OpenAPI writes `{param}`. */
5064
function toSpecPath(path: string): string {
5165
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
5266
}
5367

54-
/** `public` routes carry no security stanza; everything else accepts either credential the API
55-
* actually supports. Internal routes are bearer-only -- there is no cookie path to them. */
68+
/**
69+
* The security stanza a declared auth level emits.
70+
*
71+
* `public` carries none. `internal` is bearer-only -- there is no cookie path to it. `orb` and
72+
* `webhook` (#9531) exist because the ORB ingress genuinely does not authenticate the way the rest
73+
* of the API does, and collapsing them into `public` would publish a document that says these
74+
* routes need no credential at all. They need a DIFFERENT one: an ORB-issued bearer for the relay
75+
* and token endpoints, an HMAC signature header for the webhook. `requiresApiToken()` exempts both
76+
* from the LoopOver bearer check, which is what made them look public to the old path-prefix model.
77+
*/
5678
function securityFor(auth: RouteAuth): RouteConfig["security"] {
5779
if (auth === "public") return undefined;
5880
if (auth === "internal") return [{ LoopOverBearer: [] }];
81+
if (auth === "orb") return [{ OrbBearer: [] }];
82+
if (auth === "webhook") return [{ OrbWebhookSignature: [] }];
5983
return [{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }];
6084
}
6185

@@ -138,6 +162,11 @@ export function registerRouteSpec(registry: OpenAPIRegistry, options: RouteSpecO
138162
registry.registerPath({
139163
method: options.method,
140164
path: toSpecPath(options.path),
165+
request: {
166+
...(pathParameters(options.path) ?? {}),
167+
...(options.request?.body ? { body: { content: { "application/json": { schema: options.request.body } } } } : {}),
168+
...(options.request?.query ? { query: options.request.query } : {}),
169+
},
141170
operationId: options.operationId,
142171
tags: options.tags,
143172
summary: options.summary,

0 commit comments

Comments
 (0)