fix: preserve account access compatibility

This commit is contained in:
Marc-André Bombeck committed 2026-08-05 23:58:26 +02:00
1 parent e4e773d543
commit 4923db6464
23 files changed
+288 -14

No files matched your search

+18 -3
View File
@@ -36712,12 +36712,20 @@ components:
banner can name the person without joining two fields. Null when the caller is in their own record, and
ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses
one."
recordKind:
type: string
enum:
- self
- shared
- managed
description: The server-resolved kind of the record currently in view. `self` applies when `active` is null.
canSwitch:
type: boolean
description: Whether there is anywhere to switch to. Bind it directly.
required:
- accounts
- active
- recordKind
- canSwitch
additionalProperties: false
description: "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server
@@ -36743,9 +36751,8 @@ components:
enum:
- read
- write
- manage
description: The grant's resolved level, under the name v1.36.0 published. Identical to `level` — both come from one
server-side expression — and kept because shipped clients read it. New code reads `level`.
description: The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads
canonical `level`.
level:
type: string
enum:
@@ -36755,6 +36762,13 @@ components:
description: The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable
surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management),
which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.
recordKind:
type: string
enum:
- self
- shared
- managed
description: Server-resolved presentation metadata for the record. It is never accepted as an authorization input.
sections:
anyOf:
- type: array
@@ -36778,6 +36792,7 @@ components:
- displayName
- access
- level
- recordKind
- sections
- canWrite
additionalProperties: false
+2
View File
@@ -9520,6 +9520,8 @@
"description": "Akten, die andere mit dir geteilt haben.",
"levelRead": "Darf lesen",
"levelWrite": "Darf lesen und hinzufügen",
"kindShared": "Geteilte Akte",
"kindManaged": "Verwaltetes Profil",
"open": "Akte von {name} öffnen"
},
"toast": {
+2
View File
@@ -9520,6 +9520,8 @@
"description": "Records other people have shared with you.",
"levelRead": "Can view",
"levelWrite": "Can view and add",
"kindShared": "Shared record",
"kindManaged": "Managed profile",
"open": "Open {name}'s record"
},
"toast": {
+2
View File
@@ -9520,6 +9520,8 @@
"description": "Historiales que otras personas han compartido contigo.",
"levelRead": "Puede ver",
"levelWrite": "Puede ver y añadir",
"kindShared": "Registro compartido",
"kindManaged": "Perfil administrado",
"open": "Abrir el historial de {name}"
},
"toast": {
+2
View File
@@ -9520,6 +9520,8 @@
"description": "Les dossiers que d'autres personnes partagent avec vous.",
"levelRead": "Peut consulter",
"levelWrite": "Peut consulter et ajouter",
"kindShared": "Dossier partagé",
"kindManaged": "Profil géré",
"open": "Ouvrir le dossier de {name}"
},
"toast": {
+2
View File
@@ -9520,6 +9520,8 @@
"description": "Cartelle che altre persone hanno condiviso con te.",
"levelRead": "Può consultare",
"levelWrite": "Può consultare e aggiungere",
"kindShared": "Cartella condivisa",
"kindManaged": "Profilo gestito",
"open": "Apri la cartella di {name}"
},
"toast": {
+2
View File
@@ -9520,6 +9520,8 @@
"description": "Karty, które udostępniły ci inne osoby.",
"levelRead": "Może przeglądać",
"levelWrite": "Może przeglądać i dodawać",
"kindShared": "Udostępniona karta",
"kindManaged": "Zarządzany profil",
"open": "Otwórz kartę użytkownika {name}"
},
"toast": {
@@ -80,6 +80,19 @@ const NOT_A_CONSUMER = new Set([
join(SRC, "app", "api", "auth", "me", "route.ts"),
]);
const ACCOUNT_ENTRY_CONSUMER = join(
SRC,
"components",
"dashboard",
"looking-after-card.tsx",
);
const ACTIVE_RECORD_CONSUMER = join(SRC, "hooks", "use-record-capabilities.ts");
const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [
"level",
"sections",
"recordKind",
] as const;
/**
* Fields with no client reader, each with the reason it is nonetheless
* correct for the payload to carry it. An entry here is a claim; write one
@@ -192,4 +205,24 @@ describe("account payload consumer guard", () => {
expect(reason.length).toBeGreaterThan(20);
}
});
it("has non-zero entry and active-record readers for additive access fields", () => {
const entrySource = stripComments(
readFileSync(ACCOUNT_ENTRY_CONSUMER, "utf8"),
);
const activeSource = stripComments(
readFileSync(ACTIVE_RECORD_CONSUMER, "utf8"),
);
const readerCount = ACCOUNT_ACCESS_ADDITIVE_FIELDS.reduce(
(count, field) => {
expect(entrySource).toMatch(new RegExp(`entry\\.${field}(?![\\w$])`));
expect(activeSource).toMatch(new RegExp(`active\\.${field}(?![\\w$])`));
return count + 2;
},
0,
);
expect(readerCount).toBeGreaterThan(0);
});
});
@@ -56,6 +56,7 @@ const OWNER = {
access: "read" as const,
level: "read" as const,
sections: null,
recordKind: "shared" as const,
canWrite: false,
};
@@ -39,6 +39,7 @@ const OWNER = {
access: "read" as const,
level: "read" as const,
sections: null,
recordKind: "shared" as const,
canWrite: false,
};
@@ -10,6 +10,7 @@ const OWNER = {
access: "write" as const,
level: "write" as const,
sections: null,
recordKind: "shared" as const,
canWrite: true,
};
@@ -27,6 +27,7 @@ const GRANDMOTHER = {
access: "read" as const,
level: "read" as const,
sections: null,
recordKind: "shared" as const,
canWrite: false,
};
const FATHER = {
@@ -36,6 +37,7 @@ const FATHER = {
access: "write" as const,
level: "write" as const,
sections: null,
recordKind: "shared" as const,
canWrite: true,
};
@@ -97,6 +97,12 @@ export function LookingAfterCard() {
<span className="text-muted-foreground block truncate text-xs">
{levelLabel(entry, t)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{recordKindLabel(entry, t)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{scopeLabel(entry, t)}
</span>
</span>
{switchAccount.isPending && (
<Loader2
@@ -128,7 +134,34 @@ function levelLabel(
entry: AccountAccessEntry,
t: (key: string) => string,
): string {
if (entry.level === "manage") {
return t("recordSharing.row.canManage");
}
return entry.canWrite
? t("recordSharing.lookingAfter.levelWrite")
: t("recordSharing.lookingAfter.levelRead");
}
function recordKindLabel(
entry: AccountAccessEntry,
t: (key: string) => string,
): string {
return entry.recordKind === "managed"
? t("recordSharing.lookingAfter.kindManaged")
: t("recordSharing.lookingAfter.kindShared");
}
function scopeLabel(
entry: AccountAccessEntry,
t: (key: string) => string,
): string {
if (entry.sections === null) {
return t("recordSharing.row.entireRecord");
}
if (entry.sections.length === 0) {
return t("recordSharing.row.noSections");
}
return entry.sections
.map((domain) => t(`recordSharing.section.${domain}.label`))
.join(", ");
}
@@ -117,6 +117,7 @@ const OWNER_READ: AccountAccessEntry = {
access: "read" as const,
level: "read" as const,
sections: null,
recordKind: "shared" as const,
canWrite: false,
};
const OWNER_WRITE: AccountAccessEntry = {
@@ -59,6 +59,7 @@ const OWNER = {
access: "read" as const,
level: "read" as const,
sections: null,
recordKind: "shared" as const,
canWrite: false,
};
@@ -27,6 +27,7 @@ const READ_ONLY: AccountAccessEntry = {
access: "read",
level: "read",
sections: null,
recordKind: "shared",
canWrite: false,
};
@@ -39,7 +40,7 @@ const WRITABLE: AccountAccessEntry = {
const MANAGING: AccountAccessEntry = {
...READ_ONLY,
access: "manage",
access: "write",
level: "manage",
canWrite: true,
};
@@ -64,6 +65,7 @@ describe("resolveRecordCapabilities", () => {
// apart from a real one.
level: null,
sections: null,
recordKind: "self",
});
expect(resolveRecordCapabilities(undefined)).toEqual(
resolveRecordCapabilities(null),
@@ -78,6 +80,7 @@ describe("resolveRecordCapabilities", () => {
canManage: false,
level: "read",
sections: null,
recordKind: "shared",
});
});
@@ -91,6 +94,7 @@ describe("resolveRecordCapabilities", () => {
canManage: false,
level: "write",
sections: null,
recordKind: "shared",
});
});
@@ -121,6 +125,7 @@ describe("resolveRecordCapabilities", () => {
canManage: false,
level: "manage",
sections: null,
recordKind: "shared",
});
});
+7
View File
@@ -34,6 +34,7 @@ import type { AccountAccess } from "@/lib/sharing/account-access-view";
export const NO_ACCOUNT_ACCESS: AccountAccess = {
accounts: [],
active: null,
recordKind: "self",
canSwitch: false,
};
@@ -347,6 +348,12 @@ function coerceAccountAccess(value: unknown): AccountAccess {
return {
accounts: block.accounts,
active: block.active ?? null,
recordKind:
block.recordKind === "self" ||
block.recordKind === "shared" ||
block.recordKind === "managed"
? block.recordKind
: "self",
canSwitch: block.canSwitch === true,
};
}
+4
View File
@@ -98,6 +98,8 @@ export interface RecordCapabilities {
* nothing; it is a real answer and reads as one.
*/
sections: AccountAccessEntry["sections"];
/** The server-resolved kind of the record currently in view. */
recordKind: AccountAccessEntry["recordKind"] | "self";
}
/**
@@ -122,6 +124,7 @@ export function resolveRecordCapabilities(
// to tell those apart would have to guess.
level: null,
sections: null,
recordKind: "self",
};
}
return {
@@ -131,6 +134,7 @@ export function resolveRecordCapabilities(
canManage: false,
level: active.level,
sections: active.sections,
recordKind: active.recordKind,
};
}
@@ -32,6 +32,7 @@ const OWNER = {
access: "write" as const,
level: "write" as const,
sections: null,
recordKind: "shared" as const,
canWrite: true,
};
+12 -2
View File
@@ -220,15 +220,20 @@ const accountAccessEntry = z
username: z.string(),
displayName: z.string().nullable(),
access: z
.enum(["read", "write", "manage"])
.enum(["read", "write"])
.describe(
"The grant's resolved level, under the name v1.36.0 published. Identical to `level` — both come from one server-side expression — and kept because shipped clients read it. New code reads `level`.",
"The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads canonical `level`.",
),
level: z
.enum(["read", "write", "manage"])
.describe(
"The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management), which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.",
),
recordKind: z
.enum(["self", "shared", "managed"])
.describe(
"Server-resolved presentation metadata for the record. It is never accepted as an authorization input.",
),
sections: z
.array(shareSection)
.nullable()
@@ -257,6 +262,11 @@ export const accountAccessBlock = z
.describe(
"The record this session is inside right now, resolved to a full entry rather than an id to look up — so a banner can name the person without joining two fields. Null when the caller is in their own record, and ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses one.",
),
recordKind: z
.enum(["self", "shared", "managed"])
.describe(
"The server-resolved kind of the record currently in view. `self` applies when `active` is null.",
),
canSwitch: z
.boolean()
.describe("Whether there is anywhere to switch to. Bind it directly."),
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
accountGrant: {
findMany: vi.fn(),
},
},
}));
import { prisma } from "@/lib/db";
import { resolveAccountAccess } from "../account-access";
import { decodeLegacyWholeRecordAccess } from "../account-access-view";
import { LEGACY_ACCOUNT_PAYLOADS } from "../../../../tests/fixtures/v137/legacy-account-payloads";
function activeGrant({
access,
managedProfileAt = null,
}: {
access: "READ" | "WRITE" | "MANAGE";
managedProfileAt?: Date | null;
}) {
return {
id: `grant-${access.toLowerCase()}`,
access,
scopeJson: null,
acceptedAt: new Date("2026-08-01T00:00:00.000Z"),
revokedAt: null,
expiresAt: null,
grantor: {
id: "record-owner",
username: "record-owner",
displayName: "Record owner",
managedProfileAt,
},
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("resolveAccountAccess", () => {
it("keeps MANAGE compatible with legacy access while publishing its canonical level", async () => {
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
activeGrant({
access: "MANAGE",
managedProfileAt: new Date("2026-08-01T00:00:00.000Z"),
}),
] as never);
const access = await resolveAccountAccess({
user: { id: "delegate" },
session: { actingAsUserId: "record-owner" },
});
expect(access.accounts).toEqual([
expect.objectContaining({
access: "write",
level: "manage",
sections: null,
canWrite: true,
recordKind: "managed",
}),
]);
expect(access.active).toEqual(
expect.objectContaining({
level: "manage",
sections: null,
recordKind: "managed",
}),
);
expect(access.recordKind).toBe("managed");
});
it("resolves an ordinary shared record on the server", async () => {
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
activeGrant({ access: "READ" }),
] as never);
const access = await resolveAccountAccess({
user: { id: "delegate" },
session: {},
});
expect(access.accounts[0]).toEqual(
expect.objectContaining({
access: "read",
level: "read",
recordKind: "shared",
}),
);
expect(access.recordKind).toBe("self");
});
it("keeps whole-record legacy payloads decodable and scoped payloads closed", () => {
for (const fixture of LEGACY_ACCOUNT_PAYLOADS) {
const decoded = decodeLegacyWholeRecordAccess(fixture);
expect(decoded === null ? "deny" : "allow", fixture.name).toBe(
fixture.legacyDecoder,
);
if (fixture.legacyDecoder === "allow") {
expect(decoded).toBe(fixture.access);
}
}
});
});
+28 -1
View File
@@ -23,6 +23,26 @@ import type { ShareDomain } from "@/lib/sharing/scope";
/** What a grant lets its holder do, resolved. */
export type AccountAccessLevel = "read" | "write" | "manage";
/** The two values published by the original account-access contract. */
export type LegacyAccountAccessLevel = "read" | "write";
/** The server-resolved kind of record a presentation is naming. */
export type AccountRecordKind = "self" | "shared" | "managed";
/**
* Read the portion of an account-access entry that an older whole-record
* client understands. A scoped entry is refused rather than guessed at.
*/
export function decodeLegacyWholeRecordAccess(
value: unknown,
): LegacyAccountAccessLevel | null {
if (value === null || typeof value !== "object") return null;
const entry = value as { access?: unknown; sections?: unknown };
if (entry.access !== "read" && entry.access !== "write") return null;
if (entry.sections !== undefined && entry.sections !== null) return null;
return entry.access;
}
/** One account this caller may act on. */
export interface AccountAccessEntry {
/** The account whose record it is — the value `POST /api/account/switch` takes. */
@@ -37,13 +57,18 @@ export interface AccountAccessEntry {
* assigned from one expression in `account-access.ts`, so they cannot come
* apart; `level` is the name to write new code against.
*/
access: AccountAccessLevel;
access: LegacyAccountAccessLevel;
/**
* v1.37.0 — the same resolved level, under the name the three-level
* contract speaks. Read it; never derive it from `canWrite`, and never
* derive `canWrite` from it.
*/
level: AccountAccessLevel;
/**
* What kind of record this is. This is presentation metadata only; it is
* resolved by the server and never accepted as an authorization input.
*/
recordKind: AccountRecordKind;
/**
* v1.37.0 — which sections of the record this grant opens, or `null` for
* the entire record.
@@ -81,6 +106,8 @@ export interface AccountAccess {
* and always null on the Bearer transport.
*/
active: AccountAccessEntry | null;
/** The server-resolved kind of the record currently in view. */
recordKind?: AccountRecordKind;
/** Is there anywhere to switch to. */
canSwitch: boolean;
}
+20 -7
View File
@@ -104,24 +104,32 @@ export async function resolveAccountAccess(auth: {
where: { granteeId: actorId, revokedAt: null },
orderBy: { createdAt: "desc" },
include: {
grantor: { select: { id: true, username: true, displayName: true } },
grantor: {
select: {
id: true,
username: true,
displayName: true,
managedProfileAt: true,
},
},
},
});
const accounts: AccountAccessEntry[] = rows
.filter((grant) => isGrantActive(grant, now))
.map((grant) => {
// One expression, two names. `access` is what v1.36.0 published and
// what shipped clients read; `level` is what the three-level contract
// calls it. Deriving both from one lookup is what stops them saying
// different things about the same row.
// `access` is the shipped two-level field, so MANAGE remains write for
// clients that have not learned the canonical level. `level` names the
// complete three-level contract.
const level = LEVEL[grant.access];
return {
accountId: grant.grantor.id,
username: grant.grantor.username,
displayName: grant.grantor.displayName,
access: level,
access: level === "read" ? "read" : "write",
level,
recordKind:
grant.grantor.managedProfileAt === null ? "shared" : "managed",
sections: resolveGrantSections(grant.scopeJson),
canWrite: grantAllows(grant, "write", now),
};
@@ -132,5 +140,10 @@ export async function resolveAccountAccess(auth: {
? null
: (accounts.find((a) => a.accountId === stamped) ?? null);
return { accounts, active, canSwitch: accounts.length > 0 };
return {
accounts,
active,
recordKind: active?.recordKind ?? "self",
canSwitch: accounts.length > 0,
};
}