diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 412ccaab0..ca948712d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12888,7 +12888,11 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": - description: That access had already ended. + description: "Two refusals share this status. `meta.errorCode: sharing.revoke.already_ended` — that access had already + ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the grant is the last + active Guardian grant on a managed profile, and ending it would leave a record with nobody able to open it. + The floor is real and this is one of the four routes that enforce it; the way past it is to invite another + Guardian and wait for them to accept, or to delete the profile." content: application/json: schema: @@ -12925,7 +12929,11 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": - description: That access had already ended. + description: "Two refusals share this status. `meta.errorCode: sharing.renounce.already_ended` — that access had already + ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the caller is the last + active Guardian of a managed profile, so handing this access back would strand the record. Invite somebody + and wait for them to accept, or delete the profile; a Guardian may not simply walk away from a record that + has no self." content: application/json: schema: @@ -13210,9 +13218,11 @@ paths: description: "The record would be left with nobody looking after it (`meta.errorCode: managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather than silently: the caller must already be one active Guardian and the target is never the caller, so an - active target is always a second one — and two is never the one the floor refuses. The same code IS emitted - by the renounce route, by Guardian account deletion and by a data wipe, so a shared client resolver keeps - this arm. It is documented here so that a change making it reachable is a change somebody notices." + active target is always a second one — and two is never the one the floor refuses. The routes that DO emit + this code are `DELETE /api/account/grants/{id}` (the owner withdrawing the last Guardian grant), `POST + /api/account/grants/{id}/renounce` (a Guardian handing their own access back), Guardian account deletion and + a data wipe — a client resolves the code the same way wherever it arrives, which is why this arm stays. It + is documented here so that a change making it reachable through this route is a change somebody notices." content: application/json: schema: diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts index 2cce87c68..5ef5aef9f 100644 --- a/src/__tests__/managed-profile-contract.test.ts +++ b/src/__tests__/managed-profile-contract.test.ts @@ -2,9 +2,67 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { openApiPaths } from "@/lib/openapi/routes"; + const root = process.cwd(); +/** Every `route.ts` under the API tree. */ +async function apiRouteFiles(): Promise { + const entries = await readdir(path.join(root, "src/app/api"), { + recursive: true, + }); + return entries + .filter((entry) => entry.endsWith("route.ts")) + .map((entry) => path.join("src/app/api", entry)); +} + +/** The published path a route file serves, or null when it is unpublished. */ +function publishedPathFor(file: string): string | null { + const apiPath = file + .replace(/^src\/app/, "") + .replace(/\/route\.ts$/, "") + .replace(/\[([^\]]+)\]/g, "{$1}"); + return apiPath in openApiPaths ? apiPath : null; +} + describe("managed-profile contract", () => { + it("publishes the last-Guardian refusal on every published route that emits it", async () => { + // The failure this catches is the one the release audit found: the floor + // is enforced on four surfaces and was DESCRIBED on the one route that + // cannot reach it, while two routes that answer it every time said only + // "that access had already ended". Emitters are discovered from the route + // sources, so a fifth one cannot ship undescribed. + const files = await apiRouteFiles(); + expect(files.length).toBeGreaterThan(100); + + const emitters: string[] = []; + for (const file of files) { + const source = await readFile(path.join(root, file), "utf8"); + if (!source.includes("LastManagedGuardianError")) continue; + if (!source.includes("managed_profile.guardian.required")) continue; + emitters.push(file); + } + // Non-zero, and known: the two grant routes. The lifecycle surfaces that + // also raise it (account deletion, the data wipe) answer through their own + // envelopes and are not in this spec. + expect(emitters).toContain("src/app/api/account/grants/[id]/route.ts"); + expect(emitters).toContain( + "src/app/api/account/grants/[id]/renounce/route.ts", + ); + + const silent = emitters.filter((file) => { + const apiPath = publishedPathFor(file); + if (apiPath === null) return false; + return !JSON.stringify(openApiPaths[apiPath]).includes( + "managed_profile.guardian.required", + ); + }); + expect( + silent, + "published routes that emit the last-Guardian refusal without publishing it", + ).toEqual([]); + }); + it("requires a cookie-backed fresh MFA proof for creation", async () => { const source = await readFile( path.join(root, "src/app/api/managed-profiles/route.ts"), diff --git a/src/__tests__/openapi-sharing-denial.test.ts b/src/__tests__/openapi-sharing-denial.test.ts index de01b7039..c96b35114 100644 --- a/src/__tests__/openapi-sharing-denial.test.ts +++ b/src/__tests__/openapi-sharing-denial.test.ts @@ -45,6 +45,14 @@ * - Comments are stripped before matching. `.../guardians/route.ts` names both * fence helpers in a docblock explaining why it uses neither, and counting * prose would classify an actor surface as delegable. + * - It covers the PUBLISHED surface, which is not the whole delegable surface. + * 116 route modules resolve the fence; 90 of them are in the OpenAPI document + * and 26 have never been (mood entries, the record-settings tree, the + * medication intake tree, several insight reads). That gap predates this + * release and is not closed here — "frozen" means the published contract is + * complete and consistent, not that every delegable route is described. What + * this guard does guarantee is that one of those 26 cannot be published + * without its refusal, because publishing it puts it in scope on the next run. */ import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; @@ -256,6 +264,65 @@ describe("the sharing refusal every delegable path publishes", () => { expect(overreaching.map(name)).toEqual([]); }); + it("draws only from the reviewed delegable allowlists", () => { + // Containment, not equality, and per FILE rather than per verb — which is + // how `sharing-surface-guard.test.ts` keys its lists, because the + // declaration a human reviewed is "this module may act on another record", + // with the verb-level detail in the entry's prose beside it. + // + // What this closes: the guard above recomputes the delegable set from the + // route sources, so a route that started resolving the fence would be + // discovered and dressed with a refusal automatically — published as + // delegable without anybody deciding it should be. Held to the reviewed + // lists, that becomes two failures instead of a silent contract change. + const guard = readFileSync( + join(ROOT, "src/__tests__/sharing-surface-guard.test.ts"), + "utf8", + ); + const allowlist = (name: string): string[] => { + const start = guard.indexOf(`const ${name}`); + expect( + start, + `${name} is missing from the surface guard`, + ).toBeGreaterThan(-1); + const block = guard.slice(start, guard.indexOf("\n};", start)); + const keys = [...block.matchAll(/^ {2}"(app\/[^"]+)":/gm)].map( + (match) => match[1], + ); + // A block that matched nothing would make the union vacuous and the + // containment below unfalsifiable. + expect(keys.length, name).toBeGreaterThan(0); + return keys; + }; + + const reviewed = new Set([ + ...allowlist("DELEGABLE_ROUTES"), + ...allowlist("DELEGABLE_MANAGE_ROUTES"), + ...allowlist("GUARDIAN_ROUTES"), + ]); + expect(reviewed.size).toBeGreaterThan(50); + + const contributing = new Set( + fenced.map((operation) => + routeModulePath(operation.path) + .slice(join(ROOT, "src/").length) + .replaceAll("\\", "/"), + ), + ); + expect(contributing.size).toBeGreaterThan(50); + + const unreviewed = [...contributing].filter((file) => !reviewed.has(file)); + if (unreviewed.length > 0) { + throw new Error( + `${unreviewed.length} published operation(s) resolve the record fence from a module the surface guard has not reviewed:\n` + + unreviewed.map((file) => ` ❌ src/${file}`).join("\n") + + "\n\nA module becomes delegable by decision, not by discovery: add it " + + "to the matching list in sharing-surface-guard.test.ts with the " + + "reason, or take the fence back out.", + ); + } + }); + it("carries the refusal into the generated document", () => { const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8"); expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60)); diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index d17906766..b0d81c4fb 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -596,7 +596,8 @@ export const accountSharingPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, "409": { - description: "That access had already ended.", + description: + "Two refusals share this status. `meta.errorCode: sharing.revoke.already_ended` — that access had already ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the grant is the last active Guardian grant on a managed profile, and ending it would leave a record with nobody able to open it. The floor is real and this is one of the four routes that enforce it; the way past it is to invite another Guardian and wait for them to accept, or to delete the profile.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -630,7 +631,8 @@ export const accountSharingPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, "409": { - description: "That access had already ended.", + description: + "Two refusals share this status. `meta.errorCode: sharing.renounce.already_ended` — that access had already ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the caller is the last active Guardian of a managed profile, so handing this access back would strand the record. Invite somebody and wait for them to accept, or delete the profile; a Guardian may not simply walk away from a record that has no self.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -830,7 +832,7 @@ export const accountSharingPaths: NonNullable = { }, "409": { description: - "The record would be left with nobody looking after it (`meta.errorCode: managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather than silently: the caller must already be one active Guardian and the target is never the caller, so an active target is always a second one — and two is never the one the floor refuses. The same code IS emitted by the renounce route, by Guardian account deletion and by a data wipe, so a shared client resolver keeps this arm. It is documented here so that a change making it reachable is a change somebody notices.", + "The record would be left with nobody looking after it (`meta.errorCode: managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather than silently: the caller must already be one active Guardian and the target is never the caller, so an active target is always a second one — and two is never the one the floor refuses. The routes that DO emit this code are `DELETE /api/account/grants/{id}` (the owner withdrawing the last Guardian grant), `POST /api/account/grants/{id}/renounce` (a Guardian handing their own access back), Guardian account deletion and a data wipe — a client resolves the code the same way wherever it arrives, which is why this arm stays. It is documented here so that a change making it reachable through this route is a change somebody notices.", content: { "application/json": { schema: errorEnvelope } }, }, },