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
3,443 changes: 3,270 additions & 173 deletions apps/loopover-ui/public/openapi.json

Large diffs are not rendered by default.

49 changes: 1 addition & 48 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Hono, type Context } from "hono";
import { requiresApiToken } from "../auth/route-auth";
import { createWorkerPostHogErrorMiddleware } from "./worker-posthog";
import { z } from "zod";
import { parsePositiveInt } from "../utils/json";
Expand Down Expand Up @@ -6870,54 +6871,6 @@ async function isAuthorizedIngest(configuredToken: string | undefined, presented
return timingSafeEqual(presentedToken, configuredToken);
}

function requiresApiToken(path: string): boolean {
if (path === "/health") return false;
if (path === "/v1/mcp/compatibility") return false;
if (path === "/v1/mcp/finding-taxonomy") return false;
if (path === "/v1/mcp/enrichment-analyzers") return false;
if (/^\/v1\/public\/github\/repos\/[^/]+\/[^/]+\/stats$/.test(path)) return false;
if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/badge\.(svg|json)$/.test(path)) return false;
if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/quality$/.test(path)) return false;
if (path === "/v1/public/subnet-interface") return false;
if (path === "/v1/public/stats") return false;
// #9120: this route's own doc comment always claimed "safe unauthenticated" but was missing from this exact
// list, so it 401'd in prod — verified live: subnet-interface/stats/quality answered 200, this one 401'd.
if (path === "/v1/public/decision-ledger/verify") return false;
// #9266: the eval-scores transport is unauthenticated by the same design as every /v1/public/* sibling
// above -- committed to a corpus checksum, independently re-derivable, nothing gated behind a token.
if (path === "/v1/public/eval-scores") return false;
// #9269: the single-row read, added in the SAME PR as its route so the two can never drift the way #9120's
// sibling did. Regex (not a literal) because of the :seq path parameter.
if (/^\/v1\/public\/decision-ledger\/row\/[^/]+$/.test(path)) return false;
// #9270: the published anchor-signing public keys, added in the SAME PR as its route so the two cannot
// drift the way #9120's sibling did.
if (path === "/v1/public/decision-ledger/anchor-key") return false;
// #9271: the public anchor-attempt listing, added in the SAME PR as its route.
if (path === "/v1/public/decision-ledger/anchors") return false;
// #9123: the new public decision-record read route — same unauthenticated posture as its ledger-verify
// sibling immediately above, added in the SAME PR so the two can never drift apart the way #9120 did. The
// pull segment matches any non-slash text (not just digits): an invalid pull number is the ROUTE's 400 to
// return, not the auth gate's 401 — mirrors every other dynamic-segment exemption below (owner/repo use the
// same unvalidated [^/]+).
if (/^\/v1\/public\/decision-records\/[^/]+\/[^/]+\/[^/]+$/.test(path)) return false;
if (path === "/openapi.json") return false;
if (path === "/mcp") return false;
// Public OAuth draft-submission flow (LOOPOVER_REVIEW_DRAFT): the submission entry points are unauthenticated
// by design. The handlers themselves 404 when the flag is off, so this exemption is inert flag-OFF.
if (path === "/v1/drafts" || path.startsWith("/v1/drafts/")) return false;
if (path.startsWith("/v1/auth/")) return false;
if (path === "/v1/github/webhook") return false;
if (path === "/v1/orb/webhook") return false;
if (path === "/v1/orb/relay") return false;
if (path === "/v1/orb/oauth/callback") return false;
if (path === "/v1/orb/token") return false;
if (path === "/v1/orb/relay/register") return false;
if (path === "/v1/orb/relay/pull") return false;
if (path === "/v1/orb/ingest") return false;
if (path === "/v1/ams/ingest") return false;
if (path.startsWith("/v1/internal/")) return false;
return path.startsWith("/v1/");
}

// Unauthenticated, cookie-free, aggregate-only public GET endpoints (health check, homepage stats counter,
// per-repo public stats badge) -- open to any origin via a separate, credential-free CORS branch above.
Expand Down
74 changes: 74 additions & 0 deletions src/auth/route-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Which routes require a credential, as ONE model (#9531).
//
// Extracted from src/api/routes.ts so the OpenAPI builder can read the same predicate the app gates
// on. It cannot import routes.ts: that module reaches `cloudflare:` imports, and the spec is
// generated by a plain Node script (scripts/write-ui-openapi.ts) whose loader cannot resolve them.
//
// Before this, src/openapi/spec.ts carried `isProtectedPath` -- a second, path-prefix approximation
// that treated every `/v1/*` route as protected bar a short literal list. It had already drifted:
// the whole `/v1/public/decision-ledger/*` family answers 200 to an anonymous caller and was
// published as needing a bearer. A pure module both sides import is what makes that class of drift
// impossible rather than merely fixed.
//
// PURE by construction: string predicates only, no env, no I/O, no Workers types. Keep it that way
// -- the moment this file needs a runtime binding, the spec builder stops working again.

/**
* The ONE model of which routes are credential-gated (#9531).
*
* Exported so src/openapi/spec.ts derives its security stanzas from this rather than from a second,
* path-prefix approximation of it. `isProtectedPath` was that approximation, and it had already
* drifted: it treated every `/v1/*` path as protected except a short literal list, which disagreed
* with this function on the whole `/v1/public/decision-ledger/*` family among others -- so the
* published document advertised a bearer requirement on routes that answer 200 to an anonymous
* caller. Deriving from the enforcer means the document cannot say something the middleware does
* not do.
*/
export function requiresApiToken(path: string): boolean {
if (path === "/health") return false;
if (path === "/v1/mcp/compatibility") return false;
if (path === "/v1/mcp/finding-taxonomy") return false;
if (path === "/v1/mcp/enrichment-analyzers") return false;
if (/^\/v1\/public\/github\/repos\/[^/]+\/[^/]+\/stats$/.test(path)) return false;
if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/badge\.(svg|json)$/.test(path)) return false;
if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/quality$/.test(path)) return false;
if (path === "/v1/public/subnet-interface") return false;
if (path === "/v1/public/stats") return false;
// #9120: this route's own doc comment always claimed "safe unauthenticated" but was missing from this exact
// list, so it 401'd in prod — verified live: subnet-interface/stats/quality answered 200, this one 401'd.
if (path === "/v1/public/decision-ledger/verify") return false;
// #9266: the eval-scores transport is unauthenticated by the same design as every /v1/public/* sibling
// above -- committed to a corpus checksum, independently re-derivable, nothing gated behind a token.
if (path === "/v1/public/eval-scores") return false;
// #9269: the single-row read, added in the SAME PR as its route so the two can never drift the way #9120's
// sibling did. Regex (not a literal) because of the :seq path parameter.
if (/^\/v1\/public\/decision-ledger\/row\/[^/]+$/.test(path)) return false;
// #9270: the published anchor-signing public keys, added in the SAME PR as its route so the two cannot
// drift the way #9120's sibling did.
if (path === "/v1/public/decision-ledger/anchor-key") return false;
// #9271: the public anchor-attempt listing, added in the SAME PR as its route.
if (path === "/v1/public/decision-ledger/anchors") return false;
// #9123: the new public decision-record read route — same unauthenticated posture as its ledger-verify
// sibling immediately above, added in the SAME PR so the two can never drift apart the way #9120 did. The
// pull segment matches any non-slash text (not just digits): an invalid pull number is the ROUTE's 400 to
// return, not the auth gate's 401 — mirrors every other dynamic-segment exemption below (owner/repo use the
// same unvalidated [^/]+).
if (/^\/v1\/public\/decision-records\/[^/]+\/[^/]+\/[^/]+$/.test(path)) return false;
if (path === "/openapi.json") return false;
if (path === "/mcp") return false;
// Public OAuth draft-submission flow (LOOPOVER_REVIEW_DRAFT): the submission entry points are unauthenticated
// by design. The handlers themselves 404 when the flag is off, so this exemption is inert flag-OFF.
if (path === "/v1/drafts" || path.startsWith("/v1/drafts/")) return false;
if (path.startsWith("/v1/auth/")) return false;
if (path === "/v1/github/webhook") return false;
if (path === "/v1/orb/webhook") return false;
if (path === "/v1/orb/relay") return false;
if (path === "/v1/orb/oauth/callback") return false;
if (path === "/v1/orb/token") return false;
if (path === "/v1/orb/relay/register") return false;
if (path === "/v1/orb/relay/pull") return false;
if (path === "/v1/orb/ingest") return false;
if (path === "/v1/ams/ingest") return false;
if (path.startsWith("/v1/internal/")) return false;
return path.startsWith("/v1/");
}
41 changes: 37 additions & 4 deletions src/openapi/define-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { z } from "zod";
* declaration the runtime gate enforces -- replacing `isProtectedPath()`, a second, path-prefix
* model of the same policy that had already drifted out of agreement with it.
*/
export type RouteAuth = "public" | "token" | "session" | "internal";
export type RouteAuth = "public" | "token" | "session" | "internal" | "orb" | "webhook";

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

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

/**
* Every `:param` in the path, as an OpenAPI `in: path` parameter schema.
*
* Emitted automatically rather than declared per route: a templated segment with no matching
* parameter is a schema-validation warning (Cloudflare 30046) and leaves a generated client with a
* URL it cannot fill, and there is no case where a path parameter is optional -- so the correct
* declaration is fully derivable from the path itself and nothing is gained by asking for it twice.
*/
function pathParameters(path: string): { params: z.ZodObject } | undefined {
const names = [...path.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1]!);
if (names.length === 0) return undefined;
return { params: z.object(Object.fromEntries(names.map((name) => [name, z.string()]))) };
}

/** Hono writes `:param`; OpenAPI writes `{param}`. */
function toSpecPath(path: string): string {
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
}

/** `public` routes carry no security stanza; everything else accepts either credential the API
* actually supports. Internal routes are bearer-only -- there is no cookie path to them. */
/**
* The security stanza a declared auth level emits.
*
* `public` carries none. `internal` is bearer-only -- there is no cookie path to it. `orb` and
* `webhook` (#9531) exist because the ORB ingress genuinely does not authenticate the way the rest
* of the API does, and collapsing them into `public` would publish a document that says these
* routes need no credential at all. They need a DIFFERENT one: an ORB-issued bearer for the relay
* and token endpoints, an HMAC signature header for the webhook. `requiresApiToken()` exempts both
* from the LoopOver bearer check, which is what made them look public to the old path-prefix model.
*/
function securityFor(auth: RouteAuth): RouteConfig["security"] {
if (auth === "public") return undefined;
// `[]`, not undefined: an empty security array is OpenAPI's explicit "this operation needs no
// credential", where an ABSENT one means "not stated". The distinction is load-bearing here --
// applySecurityMetadata fills in the legacy `registerPath` calls that never declared anything, and
// it can only tell those apart from a deliberately public route if public says so out loud.
if (auth === "public") return [];
if (auth === "internal") return [{ LoopOverBearer: [] }];
if (auth === "orb") return [{ OrbBearer: [] }];
if (auth === "webhook") return [{ OrbWebhookSignature: [] }];
return [{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }];
}

Expand Down Expand Up @@ -138,6 +166,11 @@ export function registerRouteSpec(registry: OpenAPIRegistry, options: RouteSpecO
registry.registerPath({
method: options.method,
path: toSpecPath(options.path),
request: {
...(pathParameters(options.path) ?? {}),
...(options.request?.body ? { body: { content: { "application/json": { schema: options.request.body } } } } : {}),
...(options.request?.query ? { query: options.request.query } : {}),
},
operationId: options.operationId,
tags: options.tags,
summary: options.summary,
Expand Down
Loading
Loading