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/__tests__/proxy-retired-routes.test.ts b/src/__tests__/proxy-retired-routes.test.ts new file mode 100644 index 000000000..97c43ea4d --- /dev/null +++ b/src/__tests__/proxy-retired-routes.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +import { + RETIRED_ROUTES, + RETIRED_ROUTE_ERROR_CODE, +} from "@/lib/http/retired-routes"; +import { apiError } from "@/lib/api-response"; + +/** + * A retired path answers 410; nothing else changes. + * + * The failure this pins is not a crash — it is an answer that carries no + * information. `/api/auth/me/research-mode` was removed on purpose and + * answered a bare 404 for twelve days, which the native client read as a + * transient fault and retried behind a button that could never succeed. 410 is + * the status whose meaning is "this existed and is gone", so a client learns to + * stop from the status line alone; the envelope carries the removing version + * and the replacement for one that reads further. + * + * The three cases that matter are the three answers a path can get: retired, + * live, and never-existed. The last one is the one a careless fix breaks — + * turning every unknown path into a 410 would be worse than the 404 it + * replaced, because it would assert history that never happened. + * + * Break-proofs, each run and confirmed red: + * + * - Remove the retirement block from `src/proxy.ts` → five cases fail. + * - Answer 404 instead of 410 → three fail, including the anonymous one. + * - Turn the exact lookup into a `startsWith` → only the unknown-path case + * fails, which is the case that exists for exactly that mistake. + * - Drop `errorCode` from the envelope → the first case and the envelope + * equivalence both fail. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +function request(pathname: string, method = "GET"): NextRequest { + return new NextRequest(`http://localhost${pathname}`, { method }); +} + +async function body(response: Response) { + return (await response.json()) as { + data: null; + error: string; + meta: { + errorCode: string; + removedIn: string; + replacedBy: string | null; + }; + }; +} + +describe("the proxy answers a retired path with 410", () => { + it("answers 410 with the code, the removing version and the replacement", async () => { + const response = proxy(request("/api/auth/me/research-mode")); + + expect(response.status).toBe(410); + const payload = await body(response); + expect(payload.data).toBeNull(); + expect(payload.meta.errorCode).toBe(RETIRED_ROUTE_ERROR_CODE); + expect(payload.meta.removedIn).toBe("1.37.2"); + expect(payload.meta.replacedBy).toBeNull(); + expect(payload.error).toMatch(/removed in v1\.37\.2/); + }); + + it("carries the replacement path for a route that moved rather than went", async () => { + const payload = await body(proxy(request("/api/fhir/$everything"))); + + expect(payload.meta.replacedBy).toBe("/api/fhir/Patient/$everything"); + }); + + it("answers every retired path, whatever the verb", async () => { + // The path was retired, not a selection of methods on it. A client that + // POSTed to `/api/bugreport` must not get a 404 for the verb it used while + // a GET gets the tombstone. + for (const route of RETIRED_ROUTES) { + for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) { + const response = proxy(request(route.path, method)); + expect( + response.status, + `${method} ${route.path} did not answer 410`, + ).toBe(410); + } + } + }); + + it("needs no session and reads no credential", async () => { + // The request below carries nothing: no session cookie, no Authorization + // header. If the answer ever depended on one, a retired path would become + // a way to tell an authenticated caller from an anonymous one. + const anonymous = proxy(request("/api/auth/me/research-mode")); + const withSession = new NextRequest( + "http://localhost/api/auth/me/research-mode", + ); + withSession.cookies.set("healthlog_session", "whatever"); + + expect(anonymous.status).toBe(410); + expect(proxy(withSession).status).toBe(410); + expect(await body(anonymous)).toEqual(await body(proxy(withSession))); + }); + + it("carries the baseline security headers like every other early exit", () => { + const response = proxy(request("/api/auth/me/research-mode")); + + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(response.headers.get("X-Frame-Options")).toBe("DENY"); + }); + + it("leaves a live path alone", () => { + // `/api/measurements` exists. The proxy must fall through to it, not decide + // anything about it. + expect(proxy(request("/api/measurements")).status).not.toBe(410); + expect(proxy(request("/api/auth/me")).status).not.toBe(410); + // The replacement for a moved route is a live path and must stay one. + expect(proxy(request("/api/fhir/Patient/$everything")).status).not.toBe( + 410, + ); + }); + + it("leaves a genuinely unknown path to the ordinary 404", () => { + // Nothing here claims this path ever existed, so the proxy must not answer + // it at all — Next serves its own 404 downstream of the fall-through. + for (const unknown of [ + "/api/auth/me/research-modes", + "/api/auth/me/research-mode/extra", + "/api/no-such-thing", + "/api/bugreport-x", + ]) { + expect(proxy(request(unknown)).status, unknown).not.toBe(410); + } + }); +}); + +describe("the 410 body is the standard error envelope", () => { + it("is byte-identical to what apiError would have produced", async () => { + // The proxy bundles separately from the route tree, so the envelope is + // built as a plain object rather than by calling `apiError`. That is a + // duplicated shape unless something holds the two together; this is that + // something. A field added to the envelope builder and not to the + // retirement answer fails here. + const route = RETIRED_ROUTES[0]; + const fromProxy = await proxy(request(route.path)).json(); + const fromEnvelope = await apiError( + `This endpoint was removed in v${route.removedIn} and is not coming back. Do not retry.`, + 410, + { + errorCode: RETIRED_ROUTE_ERROR_CODE, + removedIn: route.removedIn, + replacedBy: route.replacedBy, + }, + ).json(); + + expect(JSON.stringify(fromProxy)).toBe(JSON.stringify(fromEnvelope)); + }); +}); 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/http/retired-routes.ts b/src/lib/http/retired-routes.ts new file mode 100644 index 000000000..0f38cc2a0 --- /dev/null +++ b/src/lib/http/retired-routes.ts @@ -0,0 +1,188 @@ +/** + * Routes that existed, answered requests, and are gone. + * + * A route removed on purpose still leaves a client calling it. As reported in + * iOS #94: between 2026-08-08 and 2026-08-20 the native client asked + * `/api/auth/me/research-mode` on every GLP-1 detail screen, got a bare 404, + * and painted a permanent error card with a retry button aimed at the same + * removed path. Twelve days, on every reachable instance including the public + * demo, which was built after the removal. Nothing about deleting the route was + * wrong — the removing commit reasons the decision out and states the target + * state for clients in words. What was wrong is that + * the answer said nothing: a 404 from a deployment is indistinguishable from a + * broken build, a reverse proxy pointed at the wrong upstream, or a typo in the + * client's own path, so treating it as transient and retrying is the correct + * reading of the only evidence the client had. + * + * ## Why 410 and not a 404 with a code + * + * Both were on the table. 410 wins because it is the status whose entire + * meaning is "this existed here and is gone", so a client that understands + * nothing else about this API — a generated stub, a retry policy written years + * ago, an HTTP cache — still learns not to retry. A 404 carrying + * `meta.errorCode` only reaches a client that reads our envelope AND branches + * on that field, which is the client we already have and not the one that broke. + * The error code is not dropped: it rides along in the same envelope, so a + * client that does read the body gets the removing version and the replacement + * as well. The status is the floor; the code is the detail. + * + * ## Where the answer is produced + * + * `src/proxy.ts`, before routing. Not a catch-all route module, for two + * reasons. A catch-all under `/api` would intercept every unmatched path, so + * the ordinary 404 for a genuinely unknown path would become ours to + * manufacture — a much larger blast radius than the problem. And the proxy + * already owns the exact-path decisions of this shape (the legacy 301 tables, + * the public-path allowlist, the demo-mode block), so this is the table that + * already exists rather than a new mechanism beside it. + * + * The answer needs no session and reveals nothing a 404 would not: the set + * below is a compile-time constant, identical for an anonymous caller and an + * authenticated one, and it names only paths that were public knowledge in the + * published contract while they lived. It is not an authentication oracle + * because it never consults the request's credentials at all. + * + * ## What goes in this list + * + * The rule is reachability, not evidence of a victim: a path belongs here when + * something outside this repository could hold a reference to it, meaning it + * was in the published contract or was a documented surface a client could be + * built against. A tombstone is worth writing whether or not anything is still + * calling — it costs one row, and the alternative is a 404 that means nothing. + * + * Whether a particular client still calls one of these is a SEPARATE question + * and this file does not answer it. It cannot: that evidence lives in the + * client's repository, not in this one, and an inference drawn from this side + * would be a guess wearing a citation. Nothing below is a claim about a caller. + * + * How the set was found: every route path under `src/app` at every release tag, + * unioned, then differenced against the current tree. Twenty-seven paths have + * been removed across the project's history. The five here are the ones that + * were published or client-facing. The other twenty-two are admin-console + * routes, operator diagnostics, aggregators superseded in place, and the + * moodLog bridge — surfaces reachable only from the operator's own browser, + * which ships with the server and reads no contract to find out what it talks + * to. `moodlog-removal-guard.test.ts` separately holds that bridge removed. + * + * Sample tags instead of all of them and this undercounts badly: six evenly + * spaced tags find fourteen of the twenty-seven, because a path added and + * removed between two samples is invisible to both. + * + * An entry is permanent. The point of a tombstone is that it outlives the + * memory of the removal, so entries accumulate rather than being pruned once + * they "must have been noticed by now". + * + * `src/__tests__/openapi-route-coverage-guard.test.ts` holds the three ends + * together: every path here is absent from `src/app`, published in the + * contract, and the only kind of published path allowed to have no route + * behind it. + */ + +/** Verbs a route module can export. Mirrors the coverage guard's set. */ +export type RetiredMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface RetiredRoute { + /** The path as it was published, in OpenAPI template form. */ + readonly path: string; + /** The release the removal shipped in, without the `v`. */ + readonly removedIn: string; + /** Where the capability went, or null when it went nowhere. */ + readonly replacedBy: string | null; + /** One sentence a client author can act on. */ + readonly reason: string; + /** + * The verbs the route exported before it was removed, which is what the + * contract publishes as gone. Every verb answers 410 regardless — the path + * is what was retired, not a selection of methods on it. + */ + readonly methods: readonly RetiredMethod[]; +} + +/** + * `meta.errorCode` on every retirement answer. One code, not one per route: + * the branch a client needs is "gone, stop retrying", and the path it asked + * for already says which one. + */ +export const RETIRED_ROUTE_ERROR_CODE = "route.retired"; + +/** HTTP status every retired path answers with. */ +export const RETIRED_ROUTE_STATUS = 410; + +export const RETIRED_ROUTES: readonly RetiredRoute[] = [ + { + path: "/api/bugreport", + removedIn: "1.23.0", + replacedBy: null, + reason: + "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.", + methods: ["POST"], + }, + { + path: "/api/bugreport/status", + removedIn: "1.23.0", + replacedBy: null, + reason: + "Told a client whether the in-app bug reporter was configured. Removed with the reporter itself.", + methods: ["GET"], + }, + { + path: "/api/auth/me/doctor-report-prefs", + removedIn: "1.32.39", + replacedBy: "/api/auth/me/report-selection", + reason: + "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.", + methods: ["GET", "PUT"], + }, + { + path: "/api/fhir/$everything", + removedIn: "1.32.39", + replacedBy: "/api/fhir/Patient/$everything", + reason: + "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.", + methods: ["GET"], + }, + { + path: "/api/auth/me/research-mode", + removedIn: "1.37.2", + replacedBy: null, + reason: + "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.", + methods: ["GET", "POST", "DELETE"], + }, +]; + +const BY_PATH: ReadonlyMap = new Map( + RETIRED_ROUTES.map((route) => [route.path, route]), +); + +/** + * The retirement for this exact path, or undefined. + * + * Exact match, deliberately: a prefix would swallow paths that never existed + * and turn an honest 404 into a claim about history. None of the registered + * paths carry a template segment today; one that did would be matched here by + * its literal form and would need a matcher rather than a lookup. + */ +export function findRetiredRoute(pathname: string): RetiredRoute | undefined { + return BY_PATH.get(pathname); +} + +/** + * The response body, in the standard error envelope. + * + * Built as a plain object rather than through `apiError` because this is + * consumed by the proxy, which bundles separately from the route tree; + * `proxy-retired-routes.test.ts` proves the two produce identical bytes, so + * "the standard envelope" is a checked claim rather than a copied shape. + */ +export function retiredRouteEnvelope(route: RetiredRoute) { + return { + data: null, + error: `This endpoint was removed in v${route.removedIn} and is not coming back. Do not retry.`, + meta: { + errorCode: RETIRED_ROUTE_ERROR_CODE, + removedIn: route.removedIn, + replacedBy: route.replacedBy, + }, + }; +} 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), + ]), + ), + ]), + ); diff --git a/src/proxy.ts b/src/proxy.ts index dd95fdf28..a43c6cb32 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,5 +1,10 @@ import { NextResponse, type NextRequest } from "next/server"; import { shouldRunWeb } from "@/lib/process-type"; +import { + findRetiredRoute, + retiredRouteEnvelope, + RETIRED_ROUTE_STATUS, +} from "@/lib/http/retired-routes"; /** * Paths that do NOT require a session cookie (public pages + external webhooks). @@ -264,6 +269,25 @@ export function proxy(request: NextRequest) { ); } + // A route that existed and is gone answers 410, not 404 — see + // `src/lib/http/retired-routes.ts` for why the status carries the message + // and the error code only fills in the detail. + // + // Placed before the demo-mode block and before the page session gate on + // purpose. A retired path is retired on a demo deploy too, and answering a + // logged-out caller with a login redirect would send them to authenticate + // for something that no longer exists. It reads nothing off the request but + // the path, so it cannot answer differently for an authenticated caller than + // for an anonymous one. + const retired = findRetiredRoute(pathname); + if (retired) { + return applyBaselineSecurityHeaders( + NextResponse.json(retiredRouteEnvelope(retired), { + status: RETIRED_ROUTE_STATUS, + }), + ); + } + // Demo mode: block all mutations except login if (process.env.DEMO_MODE === "true") { const method = request.method.toUpperCase();