From 5136ba21fa6a0c4b472ec253b9ad7e634a698beb Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 24 Aug 2026 16:38:27 -0400 Subject: [PATCH 1/2] docs(api): freeze v1 conventions and add contract drift tests (#829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand openapi.json with the canonical files/by-path, files/file-url, and files/visibility routes, add a static contract test that compares the files/usage/galleries verticals registered on the Hono app against openapi.json and docs/api.md, and extend docs/api.md with a collection envelope table and error type/code/status semantics (§1 residue + §6). --- apps/api/src/openapi-contract.test.ts | 164 +++++++++++++++++++++++ apps/web/public/.well-known/openapi.json | 113 ++++++++++++++++ docs/api.md | 35 +++++ 3 files changed, 312 insertions(+) create mode 100644 apps/api/src/openapi-contract.test.ts diff --git a/apps/api/src/openapi-contract.test.ts b/apps/api/src/openapi-contract.test.ts new file mode 100644 index 00000000..0af59b8e --- /dev/null +++ b/apps/api/src/openapi-contract.test.ts @@ -0,0 +1,164 @@ +/** + * Static drift guard between three independent descriptions of the public + * API (issue #829 §1): the routes actually registered on the Hono `app` + * (`app.routes`), the OpenAPI document served at + * `apps/web/public/.well-known/openapi.json`, and the narrative reference in + * `docs/api.md`. Deterministic — no network, no fixtures — so it fails the + * moment a canonical route is added, renamed, or removed without updating + * both docs. + * + * Scope: the canonical `/v1/workspaces/:workspace/files...`, `/usage...`, + * and `/galleries...` verticals (issue #829 §1), plus + * `/public/galleries/:id`. These are the token-authable "public developer + * API" surface documented in `docs/api.md`'s "Canonical routes" table. The + * canonical `github`/`members`/`storage`/`billing`/`comment-settings` + * verticals (issue #613 phases 2-3) are mostly session-only account + * management, covered by their own docs, and out of scope here — folding + * them in is tracked separately, not silently expanded into this guard. The + * legacy `/v1/:workspace/...` bearer-only alias family is also excluded: + * `docs/api.md`'s "Compatibility routes" section documents that whole family + * by convention rather than path by path, and the OpenAPI document does the + * same (see its description field). `KNOWN_UNDOCUMENTED` below is the + * explicit, reasoned exception list for in-scope routes that stay out of the + * OpenAPI document on purpose. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { app } from "./index"; + +interface OpenApiDoc { + paths: Record>; +} + +const HTTP_METHODS = ["get", "post", "put", "patch", "delete"]; + +const openapiPath = join(import.meta.dirname, "../../web/public/.well-known/openapi.json"); +const apiMdPath = join(import.meta.dirname, "../../../docs/api.md"); + +const openapi = JSON.parse(readFileSync(openapiPath, "utf8")) as OpenApiDoc; +const apiMd = readFileSync(apiMdPath, "utf8"); + +/** + * Hono param syntax (`:workspace`, `:key{.+}`) -> OpenAPI path templates + * (`{workspace}`, `{key}`), then every `{name}` collapses to `{param}` — the + * two documents don't always spell a path param the same way (e.g. this + * router's `:id` vs. the OpenAPI document's `{galleryId}`), and this guard + * cares about route shape, not param naming. + */ +function toComparablePath(honoOrOpenApiPath: string): string { + return honoOrOpenApiPath + .replace(/:([A-Za-z0-9_]+)(\{[^}]*\})?/g, "{$1}") + .replace(/\{[A-Za-z0-9_]+\}/g, "{param}"); +} + +/** Path prefixes under `/v1/workspaces/:workspace/` that are in scope — see the module docblock. */ +const IN_SCOPE_VERTICALS = ["files", "usage", "galleries"]; + +/** + * Canonical public-API routes registered on the live app, restricted to the + * files/usage/galleries verticals and the one public gallery read. Each is a + * "METHOD comparable-path" string. A Hono `.all(...)` route (method `ALL`) + * expands to one entry per HTTP method, matching how a real client request + * actually reaches it. + */ +function canonicalRoutes(): Set { + const routes = new Set(); + for (const route of app.routes) { + const path = route.path; + const isPublicGallery = path === "/public/galleries/:id"; + const isInScopeWorkspaceRoute = IN_SCOPE_VERTICALS.some( + (vertical) => + path === `/v1/workspaces/:workspace/${vertical}` || + path.startsWith(`/v1/workspaces/:workspace/${vertical}/`), + ); + if (!isInScopeWorkspaceRoute && !isPublicGallery) continue; + + const methods = + route.method.toLowerCase() === "all" ? HTTP_METHODS : [route.method.toLowerCase()]; + for (const method of methods) { + if (!HTTP_METHODS.includes(method)) continue; + routes.add(`${method} ${toComparablePath(path)}`); + } + } + return routes; +} + +/** + * Registered in-scope routes that are deliberately absent from the OpenAPI + * document, with the reason inline. Keep this list short — a new omission + * should be a conscious decision, not a silent gap. Empty today: the + * files-sdk folder-browser gateway (`/v1/workspaces/:workspace/file-browser`) + * is a sibling of the `files` vertical rather than a route under it, so it + * never enters `canonicalRoutes()` in the first place and needs no entry + * here. + */ +const KNOWN_UNDOCUMENTED = new Set(); + +function openApiRoutes(): Set { + const routes = new Set(); + for (const [path, methods] of Object.entries(openapi.paths)) { + if (!path.startsWith("/v1/workspaces/") && path !== "/public/galleries/{galleryId}") continue; + for (const method of Object.keys(methods)) { + if (!HTTP_METHODS.includes(method)) continue; // skips the sibling "parameters" key + routes.add(`${method} ${toComparablePath(path)}`); + } + } + return routes; +} + +describe("openapi.json vs. registered canonical routes", () => { + it("documents every registered canonical route (or lists it as a known exception)", () => { + const registered = canonicalRoutes(); + const documented = openApiRoutes(); + const missing = [...registered].filter( + (route) => !documented.has(route) && !KNOWN_UNDOCUMENTED.has(route), + ); + expect(missing).toEqual([]); + }); + + it("has no stale exceptions — every KNOWN_UNDOCUMENTED entry is still a real route", () => { + const registered = canonicalRoutes(); + const stale = [...KNOWN_UNDOCUMENTED].filter((route) => !registered.has(route)); + expect(stale).toEqual([]); + }); + + it("has no stale paths — every documented in-scope route still exists on the app", () => { + const registered = canonicalRoutes(); + const documented = openApiRoutes(); + const stale = [...documented].filter((route) => !registered.has(route)); + expect(stale).toEqual([]); + }); +}); + +describe("docs/api.md vs. registered canonical routes", () => { + it("mentions every registered canonical route's literal path at least once", () => { + const registered = new Set(); + for (const route of app.routes) { + const path = route.path; + const isPublicGallery = path === "/public/galleries/:id"; + const isInScopeWorkspaceRoute = IN_SCOPE_VERTICALS.some( + (vertical) => + path === `/v1/workspaces/:workspace/${vertical}` || + path.startsWith(`/v1/workspaces/:workspace/${vertical}/`), + ); + if (isInScopeWorkspaceRoute || isPublicGallery) registered.add(path); + } + + const undocumentedComparable = new Set( + [...KNOWN_UNDOCUMENTED].map((route) => route.split(" ")[1]), + ); + // Param *names* legitimately differ between the route source and the + // prose (`:item` vs. `:itemId`); normalize both sides to `:param` before + // substring-matching so this checks path *shape*, not naming. + const normalizedApiMd = apiMd.replace(/:([A-Za-z0-9_]+)/g, ":param"); + const toDocsPath = (honoPath: string) => + honoPath.replace(/:([A-Za-z0-9_]+)(\{[^}]*\})?/g, ":param"); + const missing = [...registered].filter( + (path) => + !undocumentedComparable.has(toComparablePath(path)) && + !normalizedApiMd.includes(toDocsPath(path)), + ); + expect(missing).toEqual([]); + }); +}); diff --git a/apps/web/public/.well-known/openapi.json b/apps/web/public/.well-known/openapi.json index a120c363..ec9243ac 100644 --- a/apps/web/public/.well-known/openapi.json +++ b/apps/web/public/.well-known/openapi.json @@ -145,6 +145,107 @@ } } }, + "/v1/workspaces/{workspace}/files/by-path": { + "parameters": [{ "$ref": "#/components/parameters/Workspace" }], + "get": { + "operationId": "listFilesByPath", + "summary": "Group workspace files by GitHub path", + "description": "Powers the web Screenshots page's grouped-by-path view. Groups recently uploaded `gh.*`-tagged files by project and path.", + "parameters": [ + { + "name": "merged", + "in": "query", + "schema": { "type": "string", "enum": ["1"] }, + "description": "Set to `1` to include only files tagged from a merged PR." + } + ], + "responses": { + "200": { + "description": "Path groups, a flat catalog, known projects, and a flat recent feed.", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/FileByPath" } } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" } + } + } + }, + "/v1/workspaces/{workspace}/files/file-url": { + "parameters": [{ "$ref": "#/components/parameters/Workspace" }], + "get": { + "operationId": "getFileUrl", + "summary": "Resolve a usable URL for a file", + "description": "Returns the workspace's public URL when configured, otherwise a short-lived signed download URL. Resolves across storage lanes so a file uploaded before a storage switch still resolves.", + "parameters": [ + { "name": "key", "in": "query", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { + "description": "A usable URL.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["url"], + "properties": { "url": { "type": "string", "format": "uri" } } + } + } + } + }, + "400": { "$ref": "#/components/responses/Validation" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/v1/workspaces/{workspace}/files/visibility": { + "parameters": [{ "$ref": "#/components/parameters/Workspace" }], + "patch": { + "operationId": "setFileVisibility", + "summary": "Set a file's visibility flag", + "parameters": [ + { "name": "key", "in": "query", "required": true, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["visibility"], + "properties": { + "visibility": { "type": "string", "enum": ["public", "private"] } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated visibility.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["key", "visibility"], + "properties": { + "key": { "type": "string" }, + "visibility": { "type": "string", "enum": ["public", "private"] } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/Validation" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" } + } + } + }, "/v1/workspaces/{workspace}/files/{key}": { "parameters": [ { "$ref": "#/components/parameters/Workspace" }, @@ -812,6 +913,18 @@ "updatedAt": { "type": "string", "format": "date-time" } } }, + "FileByPath": { + "type": "object", + "required": ["groups", "catalog", "projects", "latest", "truncated", "catalogTruncated"], + "properties": { + "groups": { "type": "array", "items": { "type": "object" } }, + "catalog": { "type": "array", "items": { "type": "object" } }, + "projects": { "type": "array", "items": { "type": "string" } }, + "latest": { "type": "array", "items": { "type": "object" } }, + "truncated": { "type": "boolean" }, + "catalogTruncated": { "type": "boolean" } + } + }, "FileFacets": { "oneOf": [ { diff --git a/docs/api.md b/docs/api.md index 0c8083ec..372d89be 100644 --- a/docs/api.md +++ b/docs/api.md @@ -72,6 +72,19 @@ Every non-2xx response uses one nested envelope (same shape as either/releases): | `message` | Human-readable; may change; never parse this | | `details` | Optional structured context for select codes | +`type` is the broad category — validation, not_found, conflict, and so on. +Every response of a given `type` uses the same default HTTP status. Branch +client logic on `code`, not `type` or `status`. Many `code` values share one +`type`. Each still names one specific failure: `key_exists`, +`idempotency_key_reused`, and `key_prefix_not_allowed` are all `validation` +or `conflict`, each with its own distinct `code`. + +An individual `AppError` can override its default HTTP status. That override +applies only to the response actually sent. It never travels on the wire, so +a client decoding the envelope always recomputes status from `type`. No route +in this API overrides status today. The mechanism exists in +`packages/errors/src/base.ts` for a case that needs it later. + Throw `AppError` subclasses from `@uploads/errors` in route code; the API's `onError` serializes them. See `packages/errors`. @@ -88,6 +101,9 @@ Throw `AppError` subclasses from `@uploads/errors` in route code; the API's | `GET /v1/workspaces/:workspace/files/:key` | Read object metadata. `?metadata=1` returns only the queryable metadata map | | `PATCH /v1/workspaces/:workspace/files/:key` | Merge queryable metadata with `{ set?, delete? }` | | `DELETE /v1/workspaces/:workspace/files/:key` | Delete an object. Returns `200 { key, deleted: true }` | +| `GET /v1/workspaces/:workspace/files/by-path?merged=` | Group recently uploaded `gh.*`-tagged files by project and path. Powers the web Screenshots page | +| `GET /v1/workspaces/:workspace/files/file-url?key=` | Resolve a usable URL for a key: the workspace's public URL, else a short-lived signed URL, else an error | +| `PATCH /v1/workspaces/:workspace/files/visibility?key=` | Set a file's `visibility` flag with `{ visibility: "public" \| "private" }` | | `GET /v1/workspaces/:workspace/usage` | Read workspace usage, limits, token scopes, and plan; requires `files:read` | | `POST /v1/workspaces/:workspace/usage/reconcile` | Maintenance operation. Potentially expensive: scans storage and rebuilds `bytes` and `objects`; bearer token only; requires `files:write` | | `POST /v1/workspaces/:workspace/usage/purge-expired` | Destructive maintenance operation. Permanently deletes objects older than `retentionDays`, then reconciles; bearer token only; requires `files:delete` | @@ -121,6 +137,25 @@ File search also keeps its pre-existing `truncated` flag. The flag reports the same thing from the other direction. `truncated: true` and a non-null `cursor` always travel together. +### Collection envelope shapes + +Every list endpoint below is a frozen v1 contract: the field set does not +change, and any new field is additive. `cursor`/`nextCursor` behave as +described above in every row. + +| Endpoint | Envelope | +| ----------------------------------------- | -------------------------------------------------------------- | +| `GET …/files` | `{ files, prefixes, cursor }` | +| `GET …/files/search` | `{ items, truncated, cursor }` | +| `GET …/files/facets` (no `key`) | `{ keys, truncated }` | +| `GET …/files/facets?key=` | `{ key, values, truncated }` | +| `GET …/galleries` | `{ galleries, nextCursor }` | +| `GET …/galleries/by-reference` | `{ galleries, nextCursor }` | +| `GET …/galleries/:id/external-references` | `{ references }` (no pagination; the set is small per gallery) | + +`nextCursor` on the gallery endpoints predates the `cursor` convention below +and keeps its name — see "Existing fields keep their names." + ## Compatibility routes The bearer-only `/v1/:workspace/files`, `/v1/:workspace/usage`, and From d79f62ce8155fe64fa4a114e8c08ccc901e2cfd0 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 24 Aug 2026 17:02:00 -0400 Subject: [PATCH 2/2] docs(api): soften envelope stability wording from frozen to stable --- docs/api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index 372d89be..fa226ce3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -139,8 +139,8 @@ always travel together. ### Collection envelope shapes -Every list endpoint below is a frozen v1 contract: the field set does not -change, and any new field is additive. `cursor`/`nextCursor` behave as +Every list endpoint below is a stable v1 contract: existing fields keep +their names and types, and any new field is additive. `cursor`/`nextCursor` behave as described above in every row. | Endpoint | Envelope |