From f4c0eb0ab90983575afb566fbccac9e11bab9d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 14:06:52 +0200 Subject: [PATCH] feat(api): publish retirement in the contract, the capability read, and the coverage guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three surfaces, one source. The registry the proxy answers 410 from now also emits the tombstones into `docs/api/openapi.yaml` and rides on `GET /api/meta/capabilities`, so a retirement cannot be honoured at runtime and invisible everywhere a client could have read it first. The contract publishes each retired path with 410 as its only response, the removing version, and the replacement. `deprecated: true` is imprecise — the word means "still works, stop using it", and these do not work — but it is what makes a generator warn at the call site, and the description carries the exact truth the flag approximates. `security: []` because the answer is produced before any handler and reads no credential; publishing it as authenticated would tell a client to sign in to be told a path is gone. The capability read carries the same list. That surface is the one a client already polls on launch, so a retirement can reach it before the failing call rather than through it. The coverage guard gains a third state. Published and exempted both describe a route that exists, so removing one used to leave exactly one legal move — erase it from the contract as well — and that move is invisible to every client already calling it. A registered path is now required to be absent from `src/app` AND present in the contract, which is the one combination the phantom-path case rejected outright. Both ways out are named in the failure message. Its docblock says what the state cannot do, because the first draft overclaimed: a check over one commit's state cannot see a deletion, so it cannot insist that a removal should have been registered. Proving that needs the previous release tag, and the job running this suite checks out at depth one with no tags, which would make the check one that silently cannot fail. What is held instead is the registration's integrity, in three directions and with the mutation for each written down. The sharing-denial guard learns the same state: a retired path resolves no record fence because it reaches no handler. --- docs/api/openapi.yaml | 255 ++++++++++++++++++ .../openapi-route-coverage-guard.test.ts | 131 ++++++++- ...openapi-security-declaration-guard.test.ts | 14 + src/__tests__/openapi-sharing-denial.test.ts | 11 + src/app/api/meta/capabilities/route.ts | 9 + src/lib/openapi/registry.ts | 5 + src/lib/openapi/routes/index.ts | 7 + src/lib/openapi/routes/meta.ts | 26 ++ src/lib/openapi/routes/retired.ts | 74 +++++ 9 files changed, 521 insertions(+), 11 deletions(-) create mode 100644 src/lib/openapi/routes/retired.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c9b39cce4..e86608581 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -46,6 +46,10 @@ tags: - name: Records - name: Admin - name: Meta + - name: Retired + description: Paths that were published and have been removed. They answer 410 Gone with `meta.errorCode` = + `route.retired`, the removing version, and the replacement path where there is one. Listed so a client generated + from this contract learns the path is gone from the contract rather than from a 404 in production. security: - bearerAuth: [] - cookieAuth: [] @@ -19347,6 +19351,221 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/bugreport: + post: + security: [] + deprecated: true + tags: + - Retired + summary: POST /api/bugreport — removed in v1.23.0 + description: >- + Removed in v1.23.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. There is no replacement; the capability is gone rather than moved. + + + The in-app bug reporter forwarded a description to a GitHub issue on the operator's behalf. It was removed with + its tables; report through the project's issue tracker instead. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.23.0 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/bugreport/status: + get: + security: [] + deprecated: true + tags: + - Retired + summary: GET /api/bugreport/status — removed in v1.23.0 + description: >- + Removed in v1.23.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. There is no replacement; the capability is gone rather than moved. + + + Told a client whether the in-app bug reporter was configured. Removed with the reporter itself. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.23.0 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/auth/me/doctor-report-prefs: + get: + security: [] + deprecated: true + tags: + - Retired + summary: GET /api/auth/me/doctor-report-prefs — removed in v1.32.39 + description: >- + Removed in v1.32.39. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. Use `/api/auth/me/report-selection` instead. + + + The doctor report grew a per-leaf selection, and the old preference blob could not express it. The replacement + stores the same intent against the current leaf catalogue. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.32.39 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + put: + security: [] + deprecated: true + tags: + - Retired + summary: PUT /api/auth/me/doctor-report-prefs — removed in v1.32.39 + description: >- + Removed in v1.32.39. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. Use `/api/auth/me/report-selection` instead. + + + The doctor report grew a per-leaf selection, and the old preference blob could not express it. The replacement + stores the same intent against the current leaf catalogue. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.32.39 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/fhir/$everything: + get: + security: [] + deprecated: true + tags: + - Retired + summary: GET /api/fhir/$everything — removed in v1.32.39 + description: >- + Removed in v1.32.39. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. Use `/api/fhir/Patient/$everything` instead. + + + The whole-record operation moved under the resource type it operates on, which is where FHIR R4 defines it and + where the capability statement points. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.32.39 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/auth/me/research-mode: + get: + security: [] + deprecated: true + tags: + - Retired + summary: GET /api/auth/me/research-mode — removed in v1.37.2 + description: >- + Removed in v1.37.2. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. There is no replacement; the capability is gone rather than moved. + + + Research Mode was an opt-in for the estimated drug-level curve. The chart had stopped consulting the flag + several releases earlier and painted for every account regardless, so the switch governed nothing. The curve is + simply part of the medication page now and needs no preference read. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.37.2 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + post: + security: [] + deprecated: true + tags: + - Retired + summary: POST /api/auth/me/research-mode — removed in v1.37.2 + description: >- + Removed in v1.37.2. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. There is no replacement; the capability is gone rather than moved. + + + Research Mode was an opt-in for the estimated drug-level curve. The chart had stopped consulting the flag + several releases earlier and painted for every account regardless, so the switch governed nothing. The curve is + simply part of the medication page now and needs no preference read. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.37.2 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + delete: + security: [] + deprecated: true + tags: + - Retired + summary: DELETE /api/auth/me/research-mode — removed in v1.37.2 + description: >- + Removed in v1.37.2. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. There is no replacement; the capability is gone rather than moved. + + + Research Mode was an opt-in for the estimated drug-level curve. The chart had stopped consulting the flag + several releases earlier and painted for every account regardless, so the switch governed nothing. The curve is + simply part of the medication page now and needs no preference read. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.37.2 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" components: schemas: CreateCycleCustomSymptomRequest: @@ -29850,6 +30069,41 @@ components: - leaves additionalProperties: false description: Clinician share-link surface descriptor. + retiredRoutes: + type: array + items: + type: object + properties: + path: + type: string + description: The path, as it was published. + removedIn: + type: string + description: Release the removal shipped in, without the `v`. + replacedBy: + anyOf: + - type: string + - type: "null" + description: Where the capability went, or null when it went nowhere. + reason: + type: string + description: Why it was removed, in one sentence, in English. + methods: + type: array + items: + type: string + description: The verbs the route exported before it was removed. Every verb answers 410 regardless — the path was + retired, not a selection of methods on it. + required: + - path + - removedIn + - replacedBy + - reason + - methods + additionalProperties: false + description: Paths this server used to serve and no longer does. Each answers 410 Gone with `meta.errorCode` = + `route.retired`. Read on launch so a client can drop the surface that depended on one before it makes the + call, rather than learning from the failure. required: - apiContractVersion - derivedMetricIds @@ -29859,6 +30113,7 @@ components: - ingest - fhir - share + - retiredRoutes additionalProperties: false description: Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit. diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 5239e1721..45114aa95 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -1,5 +1,5 @@ /** - * Every route on disk is either published or declared unpublished. + * Every route is published, declared unpublished, or registered as retired. * * `pnpm openapi:check` compares the registry against `docs/api/openapi.yaml` * and fails on drift between them. It has never compared the ROUTES against @@ -14,9 +14,45 @@ * This file closes the third side of the triangle. It walks `src/app` for * route modules, reads the HTTP verbs each one actually exports, and requires * every verb to be either present in the route table or named in - * {@link UNPUBLISHED} with a reason. There is no third outcome. Adding a route - * without touching either side fails here, which is the only place it can be - * caught before a client goes looking for a contract that was never written. + * {@link UNPUBLISHED} with a reason. Adding a route without touching either + * side fails here, which is the only place it can be caught before a client + * goes looking for a contract that was never written. + * + * ## The third state: retired + * + * Published and exempted both describe a route that exists. Removing one used + * to leave exactly one legal move — drop it from the contract too — and that + * move is invisible to every client already calling it. The native app spent + * twelve days rendering a permanent error card over a chart because + * `/api/auth/me/research-mode` went from published to absent with no third + * thing in between, and a bare 404 reads as a broken deployment rather than a + * decision. + * + * So retirement is a state here rather than a deletion. A path in + * `RETIRED_ROUTES` is required to be gone from `src/app` AND present in the + * contract, which is the one combination the fourth case below used to reject + * outright. Removing a route now forces a choice: drop it from the contract, or + * register it as retired and let the proxy answer 410 for it. What is no longer + * available is doing neither and finding out from a client. + * + * Be exact about what that is worth, because the obvious overclaim is easy to + * write and was written here first. This guard reads the tree as it stands. It + * cannot see a deletion, so it cannot insist that a removal SHOULD have been + * registered: a route deleted from `src/app` and from its route module in one + * commit leaves nothing behind to notice, and no check over a single commit's + * state can recover the fact that something used to be there. Proving otherwise + * needs an anchor outside the commit — the previous release tag — and the job + * that runs this suite checks out at depth one with no tags, so a history + * comparison here would be a check that silently cannot fail. + * + * What the third state does buy, and it is not nothing: deleting a route while + * it is still published now has TWO legal answers instead of one, and the + * failure message names both. Before, the only way past the fourth case was to + * erase the path — the move that is invisible to every client. Now the cheaper + * move is to leave the path standing as a tombstone, which is the move that + * reaches them. And once registered, the registration is held in three + * directions by the cases below, so it cannot rot into a lie about a route that + * came back or a retirement the contract never mentions. * * ## Why the exemption list carries prose * @@ -45,9 +81,30 @@ * that includes it, and asserts a floor on the number of modules found so an * enumeration that silently matches nothing cannot read as full coverage. * - * Mutation check: delete any single entry from {@link UNPUBLISHED} and this - * file fails naming that path; delete a path from a registry module and it - * fails naming the verbs that lost their contract. + * Mutation checks, each run and confirmed red: + * + * 1. Delete any single entry from {@link UNPUBLISHED} → the third case fails + * naming that path. + * 2. Delete a path from a route module → the second case fails naming the + * verbs that lost their contract. + * 3. Delete a live route module that is published — `src/app/api/version` — + * → the fourth case fails naming `/api/version` as a published path no + * route serves. This is the seam the third state opens: the failure now + * has two ways out, erase the path or register the retirement, and the + * message says so. + * 4. Recreate `src/app/api/auth/me/research-mode/route.ts` with a GET export + * → the fifth case fails naming it, because a tombstone over a live route + * is wrong in three places at once (this contract, the capability list, + * and the 410 the proxy answers before the route can ever run). + * 5. Drop `...retiredPaths` from the route-table index → the sixth case fails + * naming every registered path as answered by the server and absent from + * the contract. + * + * Deliberately NOT claimed: deleting an entry from `RETIRED_ROUTES` does not + * fail anything here, because the contract entries are generated from that same + * constant and both sides disappear together. That is the limit stated above, + * written down so the next reader does not mistake this file for a deletion + * detector. */ import { readFileSync } from "node:fs"; import { globSync } from "node:fs"; @@ -56,6 +113,7 @@ import { relative } from "node:path"; import { describe, expect, it } from "vitest"; import { openApiPaths } from "@/lib/openapi/routes"; +import { RETIRED_ROUTES } from "@/lib/http/retired-routes"; const ROOT = process.cwd(); @@ -347,8 +405,9 @@ function routesOnDisk(): DiskRoute[] { return routes; } -describe("every route is published or declared unpublished", () => { +describe("every route is published, unpublished, or retired", () => { const disk = routesOnDisk(); + const retiredPathSet = new Set(RETIRED_ROUTES.map((route) => route.path)); it("finds the route modules at all", () => { // A floor, not a count: the point is that an enumeration returning nothing @@ -403,15 +462,65 @@ describe("every route is published or declared unpublished", () => { ).toEqual([]); }); - it("the contract does not publish a path that no route serves", () => { + it("the contract does not publish a path that no route serves, unless it is retired", () => { const byPath = new Set(disk.map((route) => route.path)); const phantom = Object.keys(openApiPaths).filter( - (path) => !byPath.has(path), + (path) => !byPath.has(path) && !retiredPathSet.has(path), ); expect( phantom, - "a published path with no route behind it is a promise the deployment cannot keep", + "a published path with no route behind it is a promise the deployment cannot keep — register it in RETIRED_ROUTES if it is gone on purpose", + ).toEqual([]); + }); + + it("a retired path is really gone from src/app", () => { + // The other direction of the exemption above. A tombstone standing over a + // route that still answers would publish 410 as the only response while + // the proxy short-circuits the live handler — the contract, the capability + // list and the runtime would all be wrong together, and each of them looks + // internally consistent on its own. + const byPath = new Set(disk.map((route) => route.path)); + const undead = RETIRED_ROUTES.map((route) => route.path).filter((path) => + byPath.has(path), + ); + + expect( + undead, + "these paths are registered as retired and a route module still exports verbs for them", + ).toEqual([]); + }); + + it("every retired path is published as gone", () => { + // Registering a retirement and not publishing it puts a client back where + // it started: the proxy answers 410, and a client generated from the + // contract has never heard of the path. The retirement is only worth + // anything if it reaches the reader. + const table = openApiPaths as Record>; + const unpublished: string[] = []; + + for (const route of RETIRED_ROUTES) { + const published = table[route.path]; + if (!published) { + unpublished.push(`${route.path} (absent from the contract)`); + continue; + } + for (const method of route.methods) { + const operation = published[method.toLowerCase()] as + { responses?: Record } | undefined; + if (!operation) { + unpublished.push(`${method} ${route.path} (verb not published)`); + continue; + } + if (!operation.responses?.["410"]) { + unpublished.push(`${method} ${route.path} (published without a 410)`); + } + } + } + + expect( + unpublished, + "these retirements are answered by the server and invisible in the contract", ).toEqual([]); }); }); diff --git a/src/__tests__/openapi-security-declaration-guard.test.ts b/src/__tests__/openapi-security-declaration-guard.test.ts index 66740a788..b40a68398 100644 --- a/src/__tests__/openapi-security-declaration-guard.test.ts +++ b/src/__tests__/openapi-security-declaration-guard.test.ts @@ -42,6 +42,7 @@ import { describe, expect, it } from "vitest"; import { openApiPaths } from "@/lib/openapi/routes"; import { buildOpenApiDocument } from "@/lib/openapi/registry"; +import { RETIRED_ROUTES } from "@/lib/http/retired-routes"; const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const; @@ -78,6 +79,19 @@ const UNAUTHENTICATED: Readonly> = { "Says whether single sign-on is configured, so the sign-in screen can offer it.", "/api/notifications/vapid": "The instance's public Web Push key. Public by definition.", + // The tombstones, sourced from the registry rather than restated one by one. + // A retired path answers 410 from the proxy before any handler runs, and that + // answer never reads the request's cookies or Authorization header — so it is + // genuinely credential-free, and publishing it as authenticated would tell a + // client to sign in to be told the path is gone. Sourced rather than listed + // because a hand-written entry per retirement is a step someone forgets, and + // forgetting it here would fail the opt-out check rather than the retirement. + ...Object.fromEntries( + RETIRED_ROUTES.map((route) => [ + route.path, + `Removed in v${route.removedIn}. The 410 is produced at the edge and reads no credential.`, + ]), + ), }; describe("the contract says how each operation is authenticated", () => { diff --git a/src/__tests__/openapi-sharing-denial.test.ts b/src/__tests__/openapi-sharing-denial.test.ts index c96b35114..9d0cbeff1 100644 --- a/src/__tests__/openapi-sharing-denial.test.ts +++ b/src/__tests__/openapi-sharing-denial.test.ts @@ -61,6 +61,10 @@ import { describe, expect, it } from "vitest"; import { openApiPaths } from "@/lib/openapi/routes"; import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared"; +import { RETIRED_ROUTES } from "@/lib/http/retired-routes"; + +/** Published paths that deliberately have no handler behind them. */ +const retiredPaths = new Set(RETIRED_ROUTES.map((route) => route.path)); const ROOT = process.cwd(); const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const; @@ -148,6 +152,13 @@ function publishedOperations(): PublishedOperation[] { const operations: PublishedOperation[] = []; const missing: string[] = []; for (const [path, item] of Object.entries(openApiPaths)) { + // A retired path is published deliberately and has no route module by + // definition — that is the whole state (`src/lib/http/retired-routes.ts`). + // It resolves no fence because it reaches no handler: the proxy answers 410 + // before routing, for every caller and every verb, so there is no record to + // refuse access to. Skipped by the registry rather than by a path pattern, + // so a live route can never fall through this hole. + if (retiredPaths.has(path)) continue; const modulePath = routeModulePath(path); if (!existsSync(modulePath)) { missing.push(path); diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts index 64d99ad44..f698a3a84 100644 --- a/src/app/api/meta/capabilities/route.ts +++ b/src/app/api/meta/capabilities/route.ts @@ -47,6 +47,7 @@ import { } from "@/lib/fhir/rest"; import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link"; import { ALL_LEAF_IDS, SHARE_GROUPS } from "@/lib/report-selection/catalogue"; +import { RETIRED_ROUTES } from "@/lib/http/retired-routes"; export const dynamic = "force-dynamic"; @@ -140,5 +141,13 @@ export const GET = apiHandler(async () => { groups: SHARE_GROUPS, leaves: ALL_LEAF_IDS, }, + // Paths this server used to serve and no longer does. A capability that + // disappears from the lists above is a signal a client can act on without + // comparing versions; this is the same signal for the routes themselves, + // and it arrives on the launch read rather than on the failing call. The + // list is the same constant the proxy answers 410 from and the contract + // publishes as gone, so a retirement cannot be announced here and not + // honoured, or honoured and not announced. + retiredRoutes: RETIRED_ROUTES, }); }); diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 39c26b386..719548fea 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -79,6 +79,11 @@ const openApiBase: Pick< { name: "Records" }, { name: "Admin" }, { name: "Meta" }, + { + name: "Retired", + description: + "Paths that were published and have been removed. They answer 410 Gone with `meta.errorCode` = `route.retired`, the removing version, and the replacement path where there is one. Listed so a client generated from this contract learns the path is gone from the contract rather than from a 404 in production.", + }, ], /** * How a caller authenticates, for every operation that does not say diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 6f28752d5..ba4a04da3 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -79,6 +79,7 @@ import { nutrientPaths } from "./nutrients"; import { onboardingPaths } from "./onboarding"; import { profilePaths } from "./profile"; import { recordSettingsPaths } from "./record-settings"; +import { retiredPaths } from "./retired"; import { settingsPaths } from "./settings"; import { syncPaths } from "./sync"; import { workoutPaths } from "./workouts"; @@ -164,6 +165,12 @@ export const openApiPaths: NonNullable = { ...ingestPaths, // The environmental-context overview. Its own module because nothing else // owns the surface (appended, spread order is load-bearing). + // + // Last, and last on purpose: these are the tombstones, the only published + // paths with no route module behind them. A reader scrolling the emitted + // YAML meets the live surface before the removed one (appended, spread + // order is load-bearing). + ...retiredPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts index 08470389a..11e425392 100644 --- a/src/lib/openapi/routes/meta.ts +++ b/src/lib/openapi/routes/meta.ts @@ -140,6 +140,32 @@ const capabilitiesResponse = z ), }) .describe("Clinician share-link surface descriptor."), + retiredRoutes: z + .array( + z.object({ + path: z.string().describe("The path, as it was published."), + removedIn: z + .string() + .describe("Release the removal shipped in, without the `v`."), + replacedBy: z + .string() + .nullable() + .describe( + "Where the capability went, or null when it went nowhere.", + ), + reason: z + .string() + .describe("Why it was removed, in one sentence, in English."), + methods: z + .array(z.string()) + .describe( + "The verbs the route exported before it was removed. Every verb answers 410 regardless — the path was retired, not a selection of methods on it.", + ), + }), + ) + .describe( + "Paths this server used to serve and no longer does. Each answers 410 Gone with `meta.errorCode` = `route.retired`. Read on launch so a client can drop the surface that depended on one before it makes the call, rather than learning from the failure.", + ), }) .meta({ id: "CapabilitiesResponse", diff --git a/src/lib/openapi/routes/retired.ts b/src/lib/openapi/routes/retired.ts new file mode 100644 index 000000000..c443c41df --- /dev/null +++ b/src/lib/openapi/routes/retired.ts @@ -0,0 +1,74 @@ +/** + * OpenAPI route table — paths that were published and are gone. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * + * These are the only published paths with no route module behind them, and + * that is the point: a client generated from this contract sees the path, sees + * that its single response is 410, and gets the removing version and the + * replacement in the description rather than discovering all of it from a + * silent 404 in production. Every operation is generated from + * `RETIRED_ROUTES`, so the spec cannot claim a retirement the server does not + * answer, or miss one it does. + * + * `deprecated: true` is the closest thing OpenAPI has to a tombstone. It is + * imprecise — the word means "still works, stop using it", and these do not + * work — but it is what makes a generator emit a warning at the call site, + * which is the behaviour worth having. The description says the exact truth + * that the flag only approximates. Neither the route-coverage guard nor the + * security-declaration guard keys off the flag, so setting it changes nothing + * else. + * + * `security: []` because the 410 needs no credential: the proxy answers before + * any handler and never looks at the request's cookies or Authorization + * header. Publishing these as authenticated would tell a client to present a + * credential in order to be told the path is gone. The reason is recorded in + * `openapi-security-declaration-guard.test.ts` alongside the other opt-outs. + */ +import type { ZodOpenApiObject } from "zod-openapi"; + +import { + RETIRED_ROUTE_ERROR_CODE, + RETIRED_ROUTES, + type RetiredRoute, +} from "@/lib/http/retired-routes"; + +import { errorEnvelope } from "./shared"; + +function goneOperation(route: RetiredRoute, method: string) { + const replacement = route.replacedBy + ? `Use \`${route.replacedBy}\` instead.` + : "There is no replacement; the capability is gone rather than moved."; + + return { + security: [], + deprecated: true, + tags: ["Retired"], + summary: `${method} ${route.path} — removed in v${route.removedIn}`, + description: + `Removed in v${route.removedIn}. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. ${replacement}\n\n` + + `${route.reason}\n\n` + + "Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry.", + responses: { + "410": { + description: + `The path was removed in v${route.removedIn} and will not answer again. ` + + `\`meta.errorCode\` = \`${RETIRED_ROUTE_ERROR_CODE}\`, \`meta.removedIn\` carries the version, and \`meta.replacedBy\` carries the replacement path or null.`, + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }; +} + +export const retiredPaths: NonNullable = + Object.fromEntries( + RETIRED_ROUTES.map((route) => [ + route.path, + Object.fromEntries( + route.methods.map((method) => [ + method.toLowerCase(), + goneOperation(route, method), + ]), + ), + ]), + );