fix(sharing): keep a scoped grant out of the record's module configuration

Publishing the active record's module map and cycle flag on the account
payload was right for a whole-record grant and too generous for a narrowed
one. `accountAccess.active` is set for any live grant, level and scope
included, so a delegate scoped to `measurements` read whether the owner
tracks their cycle, their screeners, their illness episodes and their
supplement intake, and the cycle flag derives from the owner's recorded sex.

Both fields are now masked to the sections the active grant names: a closed
section answers `false`, which is the answer a module that is off already
gives and the answer the browser's route inventory has always acted on. An
unscoped grant names the whole record and is unaffected, so the own-record
and whole-record payloads are byte-identical to what they were.

The masking table lives beside the sharing vocabulary rather than inside it,
and runs one way only: no value in it can turn a module on, admit a read or
widen a grant.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 01:54:29 +02:00
1 parent ccca056f64
commit 0cccb4de99
3 files changed
+223 -14

No files matched your search

@@ -24,6 +24,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type { ShareDomain } from "@/lib/sharing/scope";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
@@ -83,7 +85,9 @@ async function signIn(userId: string) {
interface MePayload {
modules: Record<string, boolean>;
cycleTrackingEnabled: boolean;
accountAccess: { active: { accountId: string } | null };
accountAccess: {
active: { accountId: string; sections: string[] | null } | null;
};
}
async function readMe(): Promise<MePayload> {
@@ -320,3 +324,71 @@ describe("who may write the record's module map", () => {
);
});
});
describe("what a SCOPED grant is told about the record's modules", () => {
/** An owner who tracks everything, and a delegate holding one section. */
async function delegateInside(scope: ShareDomain[] | null) {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const { acceptGrant, inviteGrant } = await import("@/lib/sharing/grants");
const grant = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "READ",
scope,
});
await acceptGrant({ grantId: grant.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await switchSessionTo(session.id, owner.id);
return { owner, delegate };
}
it("answers false for every module outside the granted sections", async () => {
// The disclosure the record scoping opened: `active` is set for ANY live
// grant, so a delegate holding `measurements` would otherwise read the
// owner's cycle, screener, illness and lab configuration — and the cycle
// flag derives from the owner's recorded sex.
const { owner } = await delegateInside(["measurements"]);
const payload = await readMe();
expect(payload.accountAccess.active?.accountId).toBe(owner.id);
expect(payload.accountAccess.active?.sections).toEqual(["measurements"]);
// The owner really does track these — masked, not merely absent.
const { resolveModuleMap } = await import("@/lib/modules/gate");
const truth = await resolveModuleMap(owner.id);
for (const key of [
"cycle",
"mood",
"mentalHealth",
"illness",
"labs",
"medications",
"inboundDocuments",
] as const) {
expect(truth[key], `the record's own ${key}`).toBe(true);
expect(payload.modules[key], `the delegate's ${key}`).toBe(false);
}
expect(payload.cycleTrackingEnabled).toBe(false);
// The granted section still answers honestly, or the mask would be a
// blackout rather than a scope.
for (const key of ["sleep", "glucose", "workouts", "recovery"] as const) {
expect(payload.modules[key], `the delegate's ${key}`).toBe(true);
}
});
it("leaves an unscoped grant reading the record's true map", async () => {
// `scope: null` is the whole record, which every grant written before
// scoping carries. Masking it would break the case the record scoping was
// built for.
const { owner } = await delegateInside(null);
const payload = await readMe();
expect(payload.accountAccess.active?.sections).toBeNull();
const { resolveModuleMap } = await import("@/lib/modules/gate");
expect(payload.modules).toEqual(await resolveModuleMap(owner.id));
expect(payload.cycleTrackingEnabled).toBe(true);
});
});