mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user