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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-09 19:42:26 +02:00
1 parent 75d3fad04e
commit 54f0030ead
2 files changed
+259

No files matched your search

@@ -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", "<domain>")`,
* 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<string>;
manage: Set<string>;
matches: number;
unknown: string[];
}
function observe(): Observed {
const out: Observed = {
write: new Set(),
manage: new Set(),
matches: 0,
unknown: [],
};
const known = new Set<string>(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<ShareDomain, { write: boolean; manage: boolean }>;
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([]);
});
});
+101
View File
@@ -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", "<domain>")` 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<ShareDomain, DomainWriteSupport>
> = {
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<AccountAccessLevel, number> = {
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<ShareDomain>(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);
});
}