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/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/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();