From 1b492de0c0769854756de61f72e946ba33497c90 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:37:40 -0700 Subject: [PATCH 1/2] fix(openapi): declare in:path parameters for every {templated} path segment buildOpenApiSpec() had 27 registerPath() calls whose path templates referenced {owner}/{repo}/{number}/{id}/{login} without a matching request.params schema, so the generated spec never emitted the parameters those operations need. Cloudflare's Schema Validation flagged this as warning 30046 (path parameter expected but not found) on import against https://loopover.ai/openapi.json. Adds a regression test asserting every templated path segment across all 97 paths has a declared in:path parameter. --- apps/gittensory-ui/public/openapi.json | 438 +++++++++++++++++++++++++ src/openapi/spec.ts | 27 ++ test/unit/openapi.test.ts | 14 + 3 files changed, 479 insertions(+) diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 98ae47ca7c..9e9f74e73c 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -14340,6 +14340,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, @@ -14367,6 +14377,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, @@ -14394,6 +14414,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, @@ -14587,6 +14617,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14611,6 +14659,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14638,6 +14704,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14665,6 +14749,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14689,6 +14791,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14713,6 +14833,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14743,6 +14881,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] }, "put": { @@ -14774,6 +14930,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14804,6 +14978,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14932,6 +15124,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -14992,6 +15202,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15025,6 +15253,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15058,6 +15304,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15082,6 +15346,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15109,6 +15391,24 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15133,6 +15433,32 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "number", + "in": "path" + } ] } }, @@ -15157,6 +15483,32 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "number", + "in": "path" + } ] } }, @@ -15181,6 +15533,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } ] } }, @@ -15215,6 +15577,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } ] } }, @@ -15239,6 +15611,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } ] } }, @@ -15273,6 +15655,32 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } ] } }, @@ -15476,6 +15884,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, @@ -15690,6 +16108,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, @@ -15717,6 +16145,16 @@ { "GittensorySessionCookie": [] } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } ] } }, diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 14ad4892c1..31c06bc553 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -300,6 +300,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/installations/{id}/health", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "GitHub App installation health", content: { "application/json": { schema: InstallationHealthSchema } } }, 404: { description: "Installation health not found" }, @@ -308,6 +309,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/installations/{id}/repair", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "GitHub App installation repair diagnostics", content: { "application/json": { schema: InstallationRepairSchema } } }, 404: { description: "Installation health not found" }, @@ -316,6 +318,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/installations/{id}/repair/refresh", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "Refreshed GitHub App installation repair diagnostics", content: { "application/json": { schema: InstallationRepairSchema } } }, 404: { description: "Installation not found" }, @@ -370,6 +373,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Repository detail", content: { "application/json": { schema: RepositorySchema } } }, 404: { description: "Repository not found" }, @@ -378,6 +382,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/intelligence", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Canonical repository intelligence bundle", content: { "application/json": { schema: RepoIntelligenceSchema } } }, }, @@ -385,6 +390,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/issue-quality", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Cached or computed issue quality report for the repo", content: { "application/json": { schema: IssueQualityResponseSchema } } }, 404: { description: "Repo is unknown or has no issue-quality coverage yet" }, @@ -393,6 +399,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/outcome-patterns", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Cached or freshly-computed per-repo accepted/rejected PR outcome patterns with freshness envelope and explicit evidence-completeness", content: { "application/json": { schema: RepoOutcomePatternsResponseSchema } } }, 404: { description: "Repo is unknown or has no outcome-pattern coverage yet" }, @@ -401,6 +408,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/registration-readiness", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Gittensor registration readiness signal for repo owners", content: { "application/json": { schema: RegistrationReadinessSchema } } }, }, @@ -408,6 +416,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/gittensor-config-recommendation", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Private Gittensor config recommendation for repo owners", content: { "application/json": { schema: GittensorConfigRecommendationSchema } } }, }, @@ -415,6 +424,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/focus-manifest", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Repo focus manifest and compiled policy for maintainers", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 403: { description: "Insufficient role" }, @@ -423,6 +433,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/focus-manifest/refresh", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Refresh the persisted focus manifest cache from the repo file", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 403: { description: "Insufficient role" }, @@ -431,6 +442,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "put", path: "/v1/repos/{owner}/{repo}/focus-manifest", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Persist API-backed focus manifest for a repo", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 400: { description: "Malformed JSON request body" }, @@ -440,6 +452,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/agent/audit-feed", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: @@ -493,6 +506,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/self-dogfood-registration-pack", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Private self-dogfood registration pack when repo matches configured Gittensory target", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 403: { description: "Insufficient role or repo is not the configured self-dogfood target" }, @@ -501,6 +515,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/onboarding-pack/preview", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Preview-only repo onboarding pack for accepted repositories", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 403: { description: "Insufficient role" }, @@ -510,6 +525,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 400: { description: "Invalid request or explicit create without dryRun false" }, @@ -519,6 +535,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/settings", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Gittensory repository automation settings", content: { "application/json": { schema: RepositorySettingsSchema } } }, }, @@ -526,6 +543,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/settings-preview", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Maintainer dry-run preview of the public surface decision for a sample PR (no GitHub mutation)", content: { "application/json": { schema: RepoSettingsPreviewSchema } } }, 400: { description: "Invalid settings preview request" }, @@ -534,6 +552,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet", + request: { params: z.object({ owner: z.string(), repo: z.string(), number: z.string() }) }, responses: { 200: { description: "PR-specific maintainer review packet", content: { "application/json": { schema: PullRequestMaintainerPacketSchema } } }, }, @@ -541,6 +560,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/pulls/{number}/reviewability", + request: { params: z.object({ owner: z.string(), repo: z.string(), number: z.string() }) }, responses: { 200: { description: "Private PR reviewability score and maintainer action", content: { "application/json": { schema: PullRequestReviewabilitySchema } } }, }, @@ -548,6 +568,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/profile", + request: { params: z.object({ login: z.string() }) }, responses: { 200: { description: "Contributor evidence profile", content: { "application/json": { schema: ContributorProfileSchema } } }, }, @@ -555,6 +576,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/decision-pack", + request: { params: z.object({ login: z.string() }) }, responses: { 200: { description: "Canonical private contributor decision pack. May carry freshness 'stale' or 'rebuilding' when a background rebuild is in progress.", @@ -566,6 +588,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/open-pr-monitor", + request: { params: z.object({ login: z.string() }) }, responses: { 200: { description: "Contributor open-PR monitor with classifications and public-safe next-step packets from cached metadata.", @@ -576,6 +599,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision", + request: { params: z.object({ login: z.string(), owner: z.string(), repo: z.string() }) }, responses: { 200: { description: "Repo-specific contributor decision from decision pack. May carry freshness 'stale' or 'rebuilding'.", content: { "application/json": { schema: RepoDecisionResponseSchema } } }, 202: { description: "Decision pack snapshot is missing; a background rebuild has been requested", content: { "application/json": { schema: DecisionPackRefreshNeededSchema } } }, @@ -649,6 +673,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/agent/runs/{id}", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "Persisted agent run bundle", content: { "application/json": { schema: AgentRunBundleSchema } } }, 404: { description: "Agent run not found" }, @@ -676,6 +701,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/bounties/{id}/advisory", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "Bounty lifecycle advisory", content: { "application/json": { schema: BountyAdvisorySchema } } }, 404: { description: "Bounty not found" }, @@ -684,6 +710,7 @@ export function buildOpenApiSpec() { registry.registerPath({ method: "get", path: "/v1/bounties/{id}/lifecycle", + request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "Bounty lifecycle transition history", content: { "application/json": { schema: BountyLifecycleEventsSchema } } }, 404: { description: "Bounty not found" }, diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index ddcc3b3ca0..a625a6bf9c 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -129,4 +129,18 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/auth/logout"]?.post?.security).toBeUndefined(); expect(spec.paths["/v1/auth/extension/session"]?.post?.security).toEqual([{ GittensoryBearer: [] }, { GittensorySessionCookie: [] }]); }); + + it("declares an `in: path` parameter for every {templated} path segment (Cloudflare schema-validation warning 30046)", () => { + const spec = buildOpenApiSpec(); + for (const [path, methods] of Object.entries(spec.paths ?? {})) { + const templateParams = [...path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!); + if (templateParams.length === 0) continue; + for (const [method, operation] of Object.entries(methods as Record }>)) { + const declared = new Set((operation.parameters ?? []).filter((p) => p.in === "path").map((p) => p.name)); + for (const param of templateParams) { + expect(declared.has(param), `${method.toUpperCase()} ${path} is missing a declared path parameter for {${param}}`).toBe(true); + } + } + } + }); }); From 4ef26a1c7ee7caa9f7960baaabe1c5bbeff3c501 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:54:55 -0700 Subject: [PATCH 2/2] feat(openapi): add a size-budgeted schema generator for Cloudflare Schema Validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full public openapi.json (~480KB pretty-printed, full response bodies + component schemas + prose) exceeds Cloudflare's Free-plan zone schema-storage limit of 204800 bytes (confirmed via a real "Zone schema storage limit ... exceeded" (code 20400) upload rejection). Schema Validation only inspects incoming requests, never responses, so `npm run cloudflare:schema --silent > cloudflare-schema.json` derives a compact (~24KB) variant: strips response bodies/prose descriptions and prunes components.schemas down to whatever's still $ref-reachable (nothing, today, since every request body here is inline Zod rather than a named ref) — well under budget with real headroom as the API keeps growing. --- package.json | 1 + scripts/write-cloudflare-schema.ts | 83 +++++++++++++++++++++++ test/unit/write-cloudflare-schema.test.ts | 59 ++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 scripts/write-cloudflare-schema.ts create mode 100644 test/unit/write-cloudflare-schema.test.ts diff --git a/package.json b/package.json index d95c646b4c..f505af589b 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "ui:openapi": "tsx scripts/write-ui-openapi.ts", "ui:openapi:check": "tsx scripts/write-ui-openapi.ts --check", "ui:openapi:settings-parity": "tsx scripts/check-openapi-settings-parity.mjs", + "cloudflare:schema": "tsx scripts/write-cloudflare-schema.ts", "ui:version-audit": "node scripts/check-ui-mcp-version-copy.mjs", "docs:drift-check": "node scripts/check-docs-drift.mjs", "manifest:drift-check": "tsx scripts/check-manifest-drift.mjs", diff --git a/scripts/write-cloudflare-schema.ts b/scripts/write-cloudflare-schema.ts new file mode 100644 index 0000000000..8f35cef0af --- /dev/null +++ b/scripts/write-cloudflare-schema.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env tsx +// Cloudflare's Schema Validation (Security > Settings > Schema validation) rejects an uploaded schema +// once it exceeds the zone's schema-storage limit — 204800 bytes (200 KiB) on the Free plan. The full +// public apps/gittensory-ui/public/openapi.json (pretty-printed, full response bodies + component +// schemas + prose descriptions) is ~480KB, since it's meant for human developers and doesn't need to fit +// under a WAF's storage cap. +// +// Schema Validation only inspects INCOMING requests (path/method/parameters/request body), never +// responses, so this strips everything response- and prose-related and re-derives a minimal, +// spec-valid variant sized for upload. Prints compact (no whitespace) JSON to stdout — redirect it to a +// file to upload: `npm run cloudflare:schema --silent > cloudflare-schema.json` (plain `npm run` +// without --silent interleaves npm's own "> package@version script-name" banner into stdout ahead of +// the JSON, corrupting the file). +import { buildOpenApiSpec } from "../src/openapi/spec"; + +type JsonValue = { [key: string]: unknown } | unknown[] | string | number | boolean | null; + +export function buildCloudflareSchema(): Record { + const spec = buildOpenApiSpec() as unknown as Record; + spec.servers = [{ url: "https://api.loopover.ai", description: "Production" }]; + + const paths = spec.paths as Record>>; + for (const methods of Object.values(paths ?? {})) { + for (const operation of Object.values(methods)) { + delete operation.description; + delete operation.summary; + for (const param of (operation.parameters as Array> | undefined) ?? []) { + delete param.description; + } + const responses = operation.responses as Record> | undefined; + for (const response of Object.values(responses ?? {})) { + delete response.content; + // OpenAPI 3.0 requires responses..description to be present; Schema Validation never + // reads it, so an empty string satisfies the spec without spending upload-size budget on prose. + response.description = ""; + } + } + } + + // components.schemas is response-only in this generator (every request body is inline Zod, never a + // registered $ref) as of this writing, so it becomes fully unreferenced once response content is + // stripped above. Prune by actual reachability rather than assuming that stays true forever: walk + // every remaining $ref under paths, expand transitively through the schemas they point to, and keep + // only what is (still) reachable. + const components = spec.components as { schemas?: Record } | undefined; + const schemas = components?.schemas ?? {}; + const reachable = new Set(); + const frontier = [...collectRefs(paths)]; + while (frontier.length > 0) { + const ref = frontier.pop()!; + const name = ref.split("/").pop()!; + if (reachable.has(name)) continue; + reachable.add(name); + frontier.push(...collectRefs(schemas[name])); + } + if (components) { + components.schemas = Object.fromEntries(Object.entries(schemas).filter(([name]) => reachable.has(name))); + } + + return spec; +} + +export function collectRefs(value: unknown): string[] { + const refs: string[] = []; + walk(value as JsonValue); + return refs; + + function walk(node: JsonValue) { + if (Array.isArray(node)) { + for (const item of node) walk(item as JsonValue); + return; + } + if (node && typeof node === "object") { + const ref = (node as Record).$ref; + if (typeof ref === "string") refs.push(ref); + for (const v of Object.values(node)) walk(v as JsonValue); + } + } +} + +if (process.argv[1] && import.meta.url === new URL(process.argv[1], "file://").href) { + process.stdout.write(JSON.stringify(buildCloudflareSchema())); +} diff --git a/test/unit/write-cloudflare-schema.test.ts b/test/unit/write-cloudflare-schema.test.ts new file mode 100644 index 0000000000..0dab8cf125 --- /dev/null +++ b/test/unit/write-cloudflare-schema.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { buildCloudflareSchema, collectRefs } from "../../scripts/write-cloudflare-schema"; + +// Cloudflare Schema Validation rejects an upload once it exceeds the zone's schema-storage limit +// (204800 bytes / 200 KiB on the Free plan, confirmed against a real "Zone schema storage limit of +// 204800 bytes exceeded" (code 20400) upload error). This pins the trimmed variant well under that +// budget with real margin for the API surface to keep growing. +describe("buildCloudflareSchema", () => { + it("stays comfortably under Cloudflare's 204800-byte Free-plan schema-storage limit", () => { + const compact = JSON.stringify(buildCloudflareSchema()); + expect(compact.length).toBeLessThan(100_000); + }); + + it("is valid, spec-compliant JSON with every response carrying the OpenAPI-required description field", () => { + const spec = buildCloudflareSchema() as { paths: Record }>> }; + for (const methods of Object.values(spec.paths)) { + for (const operation of Object.values(methods)) { + for (const response of Object.values(operation.responses ?? {})) { + expect(typeof response.description).toBe("string"); + expect(response.content).toBeUndefined(); + } + } + } + }); + + it("keeps an in:path parameter declared for every {templated} path segment", () => { + const spec = buildCloudflareSchema() as { paths: Record }>> }; + for (const [path, methods] of Object.entries(spec.paths)) { + const templateParams = [...path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!); + if (templateParams.length === 0) continue; + for (const operation of Object.values(methods)) { + const declared = new Set((operation.parameters ?? []).filter((p) => p.in === "path").map((p) => p.name)); + for (const param of templateParams) expect(declared.has(param)).toBe(true); + } + } + }); + + it("prunes components.schemas down to only what's still $ref-reachable from paths", () => { + const spec = buildCloudflareSchema() as { paths: unknown; components?: { schemas?: Record } }; + const reachable = new Set(collectRefs(spec.paths).map((ref) => ref.split("/").pop())); + const kept = Object.keys(spec.components?.schemas ?? {}); + for (const name of kept) expect(reachable.has(name)).toBe(true); + }); +}); + +describe("collectRefs", () => { + it("finds every $ref in a nested structure, including inside arrays", () => { + const refs = collectRefs({ + a: { $ref: "#/components/schemas/Foo" }, + b: [{ $ref: "#/components/schemas/Bar" }, { c: { $ref: "#/components/schemas/Baz" } }], + d: "not a ref object", + }); + expect(refs.sort()).toEqual(["#/components/schemas/Bar", "#/components/schemas/Baz", "#/components/schemas/Foo"]); + }); + + it("returns an empty array when nothing references a schema", () => { + expect(collectRefs({ a: { type: "string" }, b: [1, 2, 3] })).toEqual([]); + }); +});