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), + ]), + ), + ]), + );