From 54f0030ead642eece32a8b4b12182c403c91193b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 19:42:26 +0200 Subject: [PATCH] feat(sharing): freeze which sections accept delegated writes Add the one table of which record sections have a route declaring requireRecordAuth("write") or ("manage"), derived from the route files, and a structural guard that re-derives it from src/app/api on every run and fails on a disagreement in either direction. The matcher tolerates the line break a formatter puts after the opening parenthesis and asserts a non-zero match count. delegatedDomains() turns a grant's level and scope into the sections it can add to and the sections it can change, keeping write within manage. --- .../domain-write-support-guard.test.ts | 158 ++++++++++++++++++ src/lib/sharing/domain-write-support.ts | 101 +++++++++++ 2 files changed, 259 insertions(+) create mode 100644 src/__tests__/domain-write-support-guard.test.ts create mode 100644 src/lib/sharing/domain-write-support.ts diff --git a/src/__tests__/domain-write-support-guard.test.ts b/src/__tests__/domain-write-support-guard.test.ts new file mode 100644 index 000000000..76ed8c0cb --- /dev/null +++ b/src/__tests__/domain-write-support-guard.test.ts @@ -0,0 +1,158 @@ +/** + * v1.38.12 — the delegated-write table is the routes, frozen. + * + * `src/lib/sharing/domain-write-support.ts` says, per section, whether any + * route accepts a delegated `"write"` and whether any accepts a delegated + * `"manage"`. The account payload publishes a grant's intersection with that + * table, and every add / edit / delete control in a shared record reads the + * result. So the table is an access-shaped fact about the route tree, and a + * table that drifted from the tree would either paint a control that 403s + * (table says yes, no route) or hide one the server accepts (route exists, + * table says no) — the two failures #939 was made of. + * + * This guard re-derives the table from the route files and fails on a + * disagreement in EITHER direction. It scans every `route.ts` under + * `src/app/api` for `requireRecordAuth("write" | "manage", "")`, + * whitespace-tolerant across the line break a formatter may put after the + * opening parenthesis (the matcher lesson from + * `bearer-scope-enforcement-guard.test.ts`: a literal that demanded one line + * matched nothing and was green for it). An empty match set is a failure, not + * a clean sweep. + * + * `record` is skipped on purpose: it is not a member of `SHARE_DOMAINS`, and a + * route declaring it reads across sections rather than writing to one. A + * domain outside the vocabulary other than `record` fails the run outright, + * because the table is keyed on the vocabulary and would silently lack it. + * + * Mutation checks, run: + * - set `mind.write` to `true` in the table → "agrees with the routes" red; + * - change `requireRecordAuth("manage", "mind")` in `mood-entries/route.ts` + * to `"write"` → red, in the other direction; + * - point `API_ROOT` at an empty directory → the non-zero floor goes red. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + DOMAIN_WRITE_SUPPORT, + delegatedDomains, +} from "@/lib/sharing/domain-write-support"; +import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** + * Whitespace-tolerant on both sides of the comma and after the parenthesis. + * `[a-z-]+` rather than the vocabulary, so a route naming a section this + * file has never heard of is reported instead of ignored. + */ +const DELEGATED_WRITE = + /requireRecordAuth\(\s*"(write|manage)"\s*,\s*"([a-z-]+)"/g; + +interface Observed { + write: Set; + manage: Set; + matches: number; + unknown: string[]; +} + +function observe(): Observed { + const out: Observed = { + write: new Set(), + manage: new Set(), + matches: 0, + unknown: [], + }; + const known = new Set(SHARE_DOMAINS); + for (const rel of walkSourceFiles(API_ROOT, { floor: 100 })) { + if (!rel.endsWith("route.ts")) continue; + const source = readFileSync(join(API_ROOT, rel), "utf8"); + for (const m of source.matchAll(DELEGATED_WRITE)) { + out.matches += 1; + const need = m[1] as "write" | "manage"; + const domain = m[2]; + if (domain === "record") continue; + if (!known.has(domain)) { + out.unknown.push(`${rel}: ${need} ${domain}`); + continue; + } + out[need].add(domain); + } + } + return out; +} + +describe("the delegated-write table agrees with the routes", () => { + const observed = observe(); + + it("scanned a non-trivial route tree", () => { + // Below this, the matcher is broken rather than the tree small: the + // measurements, medications, labs and profile families alone carry more. + expect(observed.matches).toBeGreaterThan(50); + expect(observed.write.size).toBeGreaterThan(0); + expect(observed.manage.size).toBeGreaterThan(0); + }); + + it("names no section outside the vocabulary", () => { + expect(observed.unknown).toEqual([]); + }); + + it("agrees with the routes, section by section, in both directions", () => { + const derived = Object.fromEntries( + SHARE_DOMAINS.map((domain) => [ + domain, + { + write: observed.write.has(domain), + manage: observed.manage.has(domain), + }, + ]), + ) as Record; + expect(DOMAIN_WRITE_SUPPORT).toEqual(derived); + }); + + it("keys the table on the whole vocabulary and nothing else", () => { + expect(Object.keys(DOMAIN_WRITE_SUPPORT).sort()).toEqual( + [...SHARE_DOMAINS].sort(), + ); + }); +}); + +describe("delegatedDomains", () => { + it("answers nothing for a read grant", () => { + expect(delegatedDomains("read", null, "write")).toEqual([]); + expect(delegatedDomains("read", null, "manage")).toEqual([]); + }); + + it("gives a write grant its write sections and no manage sections", () => { + const writable = delegatedDomains("write", null, "write"); + for (const domain of writable) { + expect(DOMAIN_WRITE_SUPPORT[domain].write, domain).toBe(true); + } + expect(writable).not.toContain("mind"); + expect(writable).not.toContain("documents"); + expect(delegatedDomains("write", null, "manage")).toEqual([]); + }); + + it("gives a manage grant every section with a delegated route, and never the vault", () => { + const writable = delegatedDomains("manage", null, "write"); + const manageable = delegatedDomains("manage", null, "manage"); + expect(manageable).toContain("mind"); + expect(manageable).toContain("profile"); + expect(manageable).not.toContain("documents"); + expect(writable).not.toContain("documents"); + // manageable ⊆ writable: a section a grant can manage is one it can add to. + for (const domain of manageable) expect(writable).toContain(domain); + }); + + it("narrows to the grant's sections, keeping consent order", () => { + expect( + delegatedDomains("write", ["labs", "measurements"], "write"), + ).toEqual(["measurements", "labs"]); + expect(delegatedDomains("write", ["documents"], "write")).toEqual([]); + expect(delegatedDomains("write", [], "write")).toEqual([]); + }); +}); diff --git a/src/lib/sharing/domain-write-support.ts b/src/lib/sharing/domain-write-support.ts new file mode 100644 index 000000000..ad182b434 --- /dev/null +++ b/src/lib/sharing/domain-write-support.ts @@ -0,0 +1,101 @@ +/** + * Which sections of a record accept a delegated write, and at which level. + * + * `requireRecordAuth(need, domain)` is declared per route, so the server has + * always known which sections a WRITE or a MANAGE grant can reach. The client + * did not: `resolveRecordCapabilities` answered `canManage: false` for every + * shared record from v1.37.0 on, a hold-back written for the release where the + * level was published before the routes behind it existed. The routes exist + * now, and the hold-back kept hiding the add, edit and delete controls on a + * managed profile whose every write the server accepts (#939). + * + * Flipping the boolean would have overshot in the other direction: the vault + * has no delegated write route at all, so a manage grant that painted the + * upload control there would paint a control that 403s. The answer is per + * section and it is the server's: this file is the one table, the account + * payload publishes the intersection of a grant with it, and the client reads + * the lists. + * + * The values are a property of the routes and nothing else, which is why they + * are frozen rather than trusted: `domain-write-support-guard.test.ts` scans + * every `route.ts` under `src/app/api` for `requireRecordAuth("write" | + * "manage", "")` and fails when this table and the routes disagree in + * either direction. Editing a value here without a route to justify it, or + * adding a delegated write route without editing here, is a red test. + * + * `record` is deliberately absent. It is not a section a grant can be narrowed + * to (`scope.ts`), and a route declaring it reads across sections; nothing a + * per-section control asks is answered by it. + */ +import type { AccountAccessLevel } from "@/lib/sharing/account-access-view"; +import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope"; + +/** Whether any route in the section declares the need. */ +export interface DomainWriteSupport { + /** At least one route declares `requireRecordAuth("write", domain)`. */ + readonly write: boolean; + /** At least one route declares `requireRecordAuth("manage", domain)`. */ + readonly manage: boolean; +} + +/** + * The table. Derived from the route files on 2026-09-09 and frozen by the + * structural guard; the reasons are in the routes, not here. + */ +export const DOMAIN_WRITE_SUPPORT: Readonly< + Record +> = { + measurements: { write: true, manage: true }, + medications: { write: true, manage: true }, + labs: { write: true, manage: true }, + profile: { write: true, manage: true }, + illness: { write: true, manage: true }, + // A mood entry and a screener are created at MANAGE, not at WRITE. + mind: { write: false, manage: true }, + // Every cycle write is a MANAGE route. + cycle: { write: false, manage: true }, + // The vault is read-only under every grant; upload, filing, sharing and the + // AI verbs stay with the owner. + documents: { write: false, manage: false }, +}; + +/** The two needs a delegated write can declare. */ +export type DelegatedWriteNeed = "write" | "manage"; + +const LEVEL_RANK: Record = { + read: 0, + write: 1, + manage: 2, +}; + +/** + * The sections a grant can write to, or manage, given its level and scope. + * + * `sections === null` is the entire record. A MANAGE grant always carries it + * (`grants.ts` refuses a scope on a MANAGE invitation), a WRITE grant may. + * + * For `need === "write"` the answer is "is there a non-read route in this + * section the grant satisfies": a WRITE grant reaches the section's `"write"` + * routes, a MANAGE grant reaches those and its `"manage"` routes as well. + * That is the `write ⊂ manage` ordering `grantAllows` enforces, stated as + * lists — a section a manage grant can manage is a section it can write to, + * so `manageable ⊆ writable` holds for every grant. + * + * For `need === "manage"` only a MANAGE grant answers, and only where a route + * declares the need. The result keeps the consent screen's order. + */ +export function delegatedDomains( + level: AccountAccessLevel, + sections: readonly ShareDomain[] | null, + need: DelegatedWriteNeed, +): ShareDomain[] { + if (LEVEL_RANK[level] < LEVEL_RANK[need]) return []; + const opened = + sections === null ? new Set(SHARE_DOMAINS) : new Set(sections); + return SHARE_DOMAINS.filter((domain) => { + if (!opened.has(domain)) return false; + const support = DOMAIN_WRITE_SUPPORT[domain]; + if (need === "manage") return support.manage; + return support.write || (level === "manage" && support.manage); + }); +}