feat: define scoped sharing levels

This commit is contained in:
Marc-André Bombeck committed 2026-08-05 23:58:18 +02:00
1 parent ab14fdb6f3
commit e4e773d543
4 files changed
+106 -10

No files matched your search

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grants";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
describe("scoped sharing contract", () => {
it("locks the closed eight-domain vocabulary", () => {
expect(SHARE_DOMAINS).toEqual([
"measurements",
"medications",
"labs",
"profile",
"illness",
"mind",
"cycle",
"documents",
]);
});
it("treats null as whole-record while refusing malformed non-null scopes", () => {
expect(normaliseScope(null)).toBeNull();
for (const malformed of [
[],
["labs", "labs"],
["labs", "unknown"],
["record"],
"labs",
{ labs: true },
]) {
const resolved = normaliseScope(malformed);
expect(resolved, JSON.stringify(malformed)).not.toBeNull();
expect(resolved?.size, JSON.stringify(malformed)).toBe(0);
for (const domain of SHARE_DOMAINS) {
expect(
grantCoversDomain({ scopeJson: malformed }, domain),
`${JSON.stringify(malformed)} must not open ${domain}`,
).toBe(false);
}
}
});
it("accepts whole or non-empty unique READ and WRITE scopes only", () => {
for (const access of ["READ", "WRITE"] as const) {
expect(
inviteGrantSchema.safeParse({
identifier: "delegate",
access,
scope: null,
}).success,
).toBe(true);
expect(
inviteGrantSchema.safeParse({
identifier: "delegate",
access,
scope: ["labs", "medications"],
}).success,
).toBe(true);
}
expect(
inviteGrantSchema.safeParse({
identifier: "delegate",
access: "READ",
scope: ["labs", "labs"],
}).success,
).toBe(false);
});
it("allows MANAGE only for the whole record", () => {
expect(
inviteGrantSchema.safeParse({
identifier: "delegate",
access: "MANAGE",
scope: null,
}).success,
).toBe(true);
expect(
inviteGrantSchema.safeParse({
identifier: "delegate",
access: "MANAGE",
scope: ["labs"],
}).success,
).toBe(false);
});
});
+8 -6
View File
@@ -422,13 +422,15 @@ describe("inviteGrant, on scope", () => {
);
});
it("writes the sections it was given, deduplicated", async () => {
it("refuses duplicate sections rather than widening their meaning", async () => {
const { db, writes } = captureScopeDb();
await inviteGrant(
{ ...pair, access: "WRITE", scope: ["labs", "medications", "labs"] },
db,
);
expect(writes[0].scopeJson).toEqual(["labs", "medications"]);
await expect(
inviteGrant(
{ ...pair, access: "WRITE", scope: ["labs", "medications", "labs"] },
db,
),
).rejects.toMatchObject({ code: "invalid_scope" });
expect(writes).toHaveLength(0);
});
it("refuses a scope that cannot mean anything, before anything is written", async () => {
+8 -4
View File
@@ -253,6 +253,10 @@ export function normaliseScope(stored: Prisma.JsonValue | null): ResolvedScope {
const domains = new Set<ShareDomain>();
for (const entry of stored) {
if (!isShareDomain(entry)) return EMPTY_SCOPE;
// A scope is a subset, not merely an array of recognisable values. A
// duplicated key is malformed rather than a second consent to the same
// domain, so it receives no partial credit.
if (domains.has(entry)) return EMPTY_SCOPE;
domains.add(entry);
}
return domains;
@@ -361,6 +365,9 @@ export async function inviteGrant(
if (input.access === "MANAGE") throw new GrantError("invalid_scope");
if (scope.length === 0) throw new GrantError("invalid_scope");
if (!scope.every(isShareDomain)) throw new GrantError("invalid_scope");
if (new Set(scope).size !== scope.length) {
throw new GrantError("invalid_scope");
}
}
const now = new Date();
@@ -377,10 +384,7 @@ export async function inviteGrant(
// to open everything. The two nulls are one keystroke apart and mean
// opposite things here.
//
// Deduplicated on the way in: a set the owner ticked twice is one
// section, and storing it twice would make two rows with the same
// meaning look different to anything that reads them back.
scopeJson: scope === null ? Prisma.DbNull : [...new Set(scope)],
scopeJson: scope === null ? Prisma.DbNull : scope,
invitedAt: now,
expiresAt: input.expiresAt ?? null,
},
+3
View File
@@ -80,6 +80,9 @@ export const inviteGrantSchema = z
scope: z
.array(z.enum(SHARE_DOMAINS))
.min(1)
.refine((scope) => new Set(scope).size === scope.length, {
message: "Each section can be selected only once",
})
.nullable()
.optional()
.describe(