mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
docs: publish the last-Guardian refusal where it is actually raised
The floor was described on the one route that cannot reach it and left unsaid on the two that answer it every time: withdrawing the last Guardian grant and renouncing it both raise `managed_profile.guardian.required`, and both published only 'that access had already ended'. Both 409s now name their two codes, and the annotation on the removal route enumerates all four emitters rather than three. A guard closes the class rather than the instance: every route module that raises the refusal must publish it on its own operation, with the emitters discovered from the sources. The freeze guard gains its containment leg — a module becomes delegable by review, not by discovery, so every file contributing a fenced verb has to be a member of one of the surface guard's frozen allowlists.
This commit is contained in:
1 parent
84475ea2f2
commit
4d86dceb99
4 files changed
+145
-8
No files matched your search
+15
-5
@@ -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:
|
||||
|
||||
@@ -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<string[]> {
|
||||
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"),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -596,7 +596,8 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"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 } },
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user