refactor(sharing): retire the coarse "may this person add" answer

`canWrite` is the grant's LEVEL and carries no scope term. Its docblock said
"May this caller ADD to that record", and the client hook republished it as
`canAdd`, so fourteen controls across measurements, medications, labs, illness
and the dashboard gated a create on a boolean that answers `true` for a WRITE
grant scoped to the document vault — a grant whose writable-section list is
empty, because the vault takes no delegated write at any level.

Nothing shipped was visibly wrong before the capture picker, because the nav
drops a section a grant does not open. That is a property of one surface, not
of the answer, and the answer is the thing many controls read.

So the coarse answer is gone from the hook, and every consumer asks
`canWriteDomain` for its own section instead: `measurements` on the two
measurement surfaces and the dashboard's empty state, `medications` on the
medication list, table, ledger, card menu and side effects, `labs` on the lab
list and biomarker detail, `illness` on the episode list. Each of those is the
section its own route declares. `canWrite` keeps its name and its wire shape —
native clients read it — and its docblock now says what it is: whether the
grant is a reading one, with the per-section lists beside it for the question a
control actually has.

The hook table gained the vault-scoped WRITE grant as its own leg: level true,
every section false. The take-all-due fixture described a grant the server
never mints — `canWrite: true` over an empty writable list — and now derives
its list from the same table the server derives it from.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 11:02:32 +02:00
1 parent b6d3f9903f
commit e7a1950f3a
25 files changed
+146 -88

No files matched your search

@@ -161,12 +161,11 @@ describe("what the canonical readers make of the same payload", () => {
expect(capabilities.sections).toEqual(fixture.sections);
expect(capabilities.recordKind).toBe(fixture.recordKind);
expect(capabilities.inSharedRecord).toBe(true);
// Resolved server-side and rendered, never re-derived: `canAdd` follows
// `canWrite`, and `canManage` follows the published `manageableDomains`
// list rather than the level — a delegate's management reach is the
// server's answer, section by section.
// Resolved server-side and rendered, never re-derived: `canWrite` is the
// published level, and both per-section answers follow the published
// lists rather than the level — a delegate's reach is the server's
// answer, section by section.
expect(capabilities.canWrite).toBe(fixture.canWrite);
expect(capabilities.canAdd).toBe(fixture.canWrite);
const manageable = active?.manageableDomains ?? [];
expect(capabilities.canManage).toBe(manageable.length > 0);
expect(manageable.length > 0).toBe(fixture.level === "manage");
+3 -2
View File
@@ -44,7 +44,8 @@ export default function LabsPage() {
// Scanning a report is not: it files a document and commits through the OCR
// route, and neither is delegated. So a WRITE delegate keeps the manual add
// and loses the choice menu around it.
const { canAdd, inSharedRecord } = useRecordCapabilities();
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -152,7 +153,7 @@ export default function LabsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : canAdd ? (
) : canAddLab ? (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
+5 -4
View File
@@ -35,7 +35,8 @@ export function resolveMeasurementReturnTo(
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -148,7 +149,7 @@ export default function MeasurementsPage() {
// admits, so this is the header action a delegate keeps. A read-only
// delegate loses it entirely rather than meeting a disabled one.
actions={
canAdd ? (
canAddMeasurement ? (
<Button
data-slot="measurement-add"
className="min-h-11 sm:min-h-9"
@@ -168,12 +169,12 @@ export default function MeasurementsPage() {
answers. `?add=<TYPE>` opens it without passing the button, and a
deep link is the same affordance as the control that produces it, so
it gets the same gate. Gating the open rather than only the param
also covers the first-paint window: `canAdd` reads true until
also covers the first-paint window: `canAddMeasurement` reads true until
`/api/auth/me` settles, and a sheet opened in that frame withdraws
when the answer lands instead of standing on a form the server
refuses. */}
<ResponsiveSheet
open={dialogOpen && canAdd}
open={dialogOpen && canAddMeasurement}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
@@ -48,6 +48,7 @@ vi.mock("@/components/medications/log-intake-dialog", () => ({
import MedicationsPage from "@/app/medications/page-client";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
const OWNER = {
accountId: "acct-owner",
@@ -75,7 +76,17 @@ const READ_ONLY: AccountAccess = {
};
const WRITABLE: AccountAccess = {
accounts: [OWNER],
active: { ...OWNER, access: "write", level: "write", canWrite: true },
active: {
...OWNER,
access: "write",
level: "write",
canWrite: true,
// Published the way the server publishes it: the sections a WRITE grant
// over the whole record can add to. A fixture that said `canWrite: true`
// over an empty list described a grant the server never mints, and the
// control asks the list.
writableDomains: delegatedDomains("write", null, "write"),
},
canSwitch: true,
};
+9 -8
View File
@@ -169,7 +169,8 @@ export default function MedicationsPageClient() {
// to tapping each card in turn — so withholding it made a caregiver with
// five morning tablets tap five times for nothing. The customize page stays
// withheld; sharing does not cover it at all.
const { canAdd, inSharedRecord } = useRecordCapabilities();
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
const canAddMedication = canWriteDomain("medications");
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
// turned off the whole page disappears (the nav entry is hidden by the same
// gate) and the list query never fires (the API would 403 anyway).
@@ -417,7 +418,7 @@ export default function MedicationsPageClient() {
card's own one-tap job). Calm outline variant so the primary
Add button keeps the visual lead; same responsive 44-px
mobile tap floor as its neighbours. */}
{canAdd && dueMeds.length >= 2 && (
{canAddMedication && dueMeds.length >= 2 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
@@ -455,7 +456,7 @@ export default function MedicationsPageClient() {
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
minimum identically. */}
{canAdd && (
{canAddMedication && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
@@ -516,7 +517,7 @@ export default function MedicationsPageClient() {
title={t("medications.emptyTitle")}
description={t("medications.emptyDescription")}
action={
canAdd ? (
canAddMedication ? (
<Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4" />
{t("medications.firstMedication")}
@@ -614,9 +615,9 @@ export default function MedicationsPageClient() {
its own ResponsiveSheet shell with the sticky footer. */}
{/* v1.36.x — `?new=1` (the retired `/medications/new` route redirects
here) opens the create wizard without passing the gated Add control,
so the wizard asks the same `canAdd` the control asks. */}
so the wizard asks the same `canAddMedication` the control asks. */}
<MedicationWizardDialog
open={dialogOpen && canAdd}
open={dialogOpen && canAddMedication}
onOpenChange={setDialogOpen}
mode="create"
onSuccess={closeDialog}
@@ -632,10 +633,10 @@ export default function MedicationsPageClient() {
attribution + inventory consumption identical to N individual
taps — see take-all-due.ts); failed medications stay due. */}
{/* Gated on the same answer the button is, for the first-paint reason
`/measurements` documents: `canAdd` reads true until `/api/auth/me`
`/measurements` documents: `canAddMedication` reads true until `/api/auth/me`
settles, and a dialog opened in that frame withdraws when the answer
lands rather than standing on a confirm the server would refuse. */}
{takeAllOpen && canAdd && (
{takeAllOpen && canAddMedication && (
<TakeAllDueDialog
open={takeAllOpen}
onOpenChange={setTakeAllOpen}
+3 -2
View File
@@ -254,7 +254,8 @@ export default function DashboardPageClient({
value: number | null | undefined,
): number | null =>
value == null ? null : unitDisplay.toDisplayDelta(type, value);
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const [quickEntryDialog, setQuickEntryDialog] =
useState<QuickEntryDialog>(null);
@@ -2028,7 +2029,7 @@ export default function DashboardPageClient({
title={t("dashboard.emptyTitle")}
description={t("dashboard.emptyDescription")}
action={
canAdd ? (
canAddMeasurement ? (
<Button
size="sm"
onClick={() => setQuickEntryDialog("measurement")}
@@ -14,8 +14,8 @@
* path is not proven at all. That leg lives in `e2e/account-sharing.spec.ts`.
*
* Mutation checks, run:
* - `useRecordCapabilities` returning `canAdd: true` unconditionally → the
* read-only legs for the intake row and the card menu go red.
* - `useRecordCapabilities` answering `canWriteDomain` for every section →
* the read-only legs for the intake row and the card menu go red.
* - `DeleteButton` dropping its `canManageDomain` bail → "no row delete inside
* somebody else's record" goes red; answering it for every section → the
* vault leg of "a guardian's row delete follows the section" goes red.
@@ -533,9 +533,10 @@ describe("the capture picker's kinds", () => {
});
it("offers a scoped WRITE delegate only the kinds its sections cover", () => {
// The gap this closes. `canAdd` is the grant's LEVEL with no scope term,
// so a labs-scoped WRITE grant answered true and the picker offered a
// weight form and a dose the server refuses at `grantCoversDomain`.
// The gap this closes. The coarse answer was the grant's LEVEL with no
// scope term, so a labs-scoped WRITE grant read as true and the picker
// offered a weight form and a dose the server refuses at
// `grantCoversDomain`.
expect(visibleCaptureKinds(caps("write", ["labs"]), [...ALL])).toEqual([]);
expect(
visibleCaptureKinds(caps("write", ["measurements"]), [...ALL]),
@@ -14,7 +14,7 @@ vi.mock("next/navigation", () => ({
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canAdd: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
+4 -3
View File
@@ -320,7 +320,8 @@ export function IllnessView() {
// v1.36.x — opening an illness entry is an admitted delegated write. Logging
// a day against one, editing it, resolving it and deleting it are not, so a
// delegate can start the record of an illness and nothing more.
const { canAdd, inSharedRecord } = useRecordCapabilities();
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
const canAddEpisode = canWriteDomain("illness");
const {
data: episodes,
isLoading,
@@ -412,7 +413,7 @@ export function IllnessView() {
)}
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
{canAdd && (
{canAddEpisode && (
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -476,7 +477,7 @@ export function IllnessView() {
description={t("illness.empty.body")}
ctaSize="lg"
action={
canAdd ? (
canAddEpisode ? (
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
+4 -3
View File
@@ -95,7 +95,8 @@ const READINGS_PAGE_SIZE = 200;
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { user } = useAuth();
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const canManageLabs = canManageDomain("labs");
const { t, locale } = useTranslations();
const router = useRouter();
@@ -378,7 +379,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
) : null}
{/* v1.36.x — entering a result is an admitted delegated write;
renaming or removing the marker is not. */}
{canAdd && (
{canAddLab && (
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
@@ -436,7 +437,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
title={t("labs.detail.emptyTitle")}
description={t("labs.detail.emptyDescription")}
action={
canAdd ? (
canAddLab ? (
<Button onClick={() => setAddOpen(true)}>
{t("labs.addResult")}
</Button>
+3 -2
View File
@@ -93,7 +93,8 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const { prefs } = useModuleListPrefs("labs");
// v1.22 — a short, factual line under each marker heading describing what the
@@ -184,7 +185,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst && canAdd ? (
onAddFirst && canAddLab ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
@@ -18,7 +18,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
@@ -11,7 +11,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
+4 -5
View File
@@ -55,11 +55,10 @@ type CaptureKind = "measurement" | "medication" | "mood";
*
* One question per kind, asked of that kind's own section. The picker used to
* ask a coarse one as well — a closed list of kinds a WRITE grant admits,
* offered whenever `canAdd` was true — and `canAdd` is bound to the grant's
* LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]`
* published `canAdd: true` and got a weight form the server refuses; one
* scoped to `["documents"]`, which takes no delegated write at any level, got
* both. `writableDomains` is already the level × scope × route-table
* offered whenever the grant could write anywhere — and that answer is the
* grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]`
* read as true and got a weight form the server refuses; one scoped to
* `["documents"]`, which takes no delegated write at any level, got both. `writableDomains` is already the level × scope × route-table
* intersection, so asking it per section answers both cases and the mood
* entry's MANAGE-only create as well.
*/
@@ -29,7 +29,7 @@ vi.mock("@/hooks/use-encounters", () => ({
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canAdd: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
@@ -292,8 +292,9 @@ export function MeasurementList({
// v1.36.x — inside somebody else's record, only the add path survives:
// entering a reading is an admitted delegated write, editing and deleting
// one are not. The row controls are dropped rather than disabled.
const { canAdd, canManage, inSharedRecord, canWrite } =
const { canWriteDomain, canManage, inSharedRecord, canWrite } =
useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const activeRecord = user?.accountAccess?.active ?? null;
// Named only when the sentence below is owed: a delegate who may add but
// not change. In the caller's own record, and for a read-only delegate who
@@ -1068,7 +1069,7 @@ export function MeasurementList({
>
{t("measurements.emptyResetFilter")}
</Button>
) : onAddFirst && canAdd ? (
) : onAddFirst && canAddMeasurement ? (
<Button onClick={onAddFirst}>
<Plus className="h-4 w-4" />
{t("measurements.emptyAddFirst")}
@@ -36,8 +36,9 @@ export function MedicationIntakeActions({
// v1.36.x — marking a dose taken or skipped is the verb the delegation was
// written for. A read-only delegate sees the card and its schedule and no
// action row; the card is otherwise unchanged.
const { canAdd } = useRecordCapabilities();
if (!canAdd) return null;
const { canWriteDomain } = useRecordCapabilities();
const canRecordIntake = canWriteDomain("medications");
if (!canRecordIntake) return null;
return (
<div className="flex gap-2">
@@ -175,7 +175,7 @@ export function LedgerAddDialog({
// confirmation names the record, because "Taken" alone is the one
// confirmation a person acting for somebody else does not need. The
// ledger's "+ Eintrag" button that opens this dialog is gated on
// `canAdd`, so a delegate reaches it.
// `canWriteDomain("medications")`, so a delegate reaches it.
const toastOptions = intakeToastOptions({
recordName,
eventId: undefined,
@@ -173,7 +173,8 @@ export function DoseHistoryLedger({
// v1.36.x — a delegate may record a dose, which is what "add" and the
// inline "mark taken" do. Editing, deleting, pinning and un-skipping an
// existing event are the owner's, so the row menu is absent for them.
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
// The record this ledger's writes land in, when it is not the caller's own.
// The two sibling intake paths in `use-medication-intake.ts` already drop
// Undo and name the record; this third one builds its own toast and missed
@@ -434,7 +435,7 @@ export function DoseHistoryLedger({
{t("medications.detail.verlauf.summaryEmpty")}
</p>
)}
{canAdd && (
{canAddIntake && (
<Button
variant="outline"
size="sm"
@@ -598,7 +599,8 @@ export function LedgerRowItem({
onUnpin: (eventId: string) => void;
}) {
const { t } = useTranslations();
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const fmt = useFormatters();
@@ -705,7 +707,7 @@ export function LedgerRowItem({
</div>
<div className="flex shrink-0 items-center gap-1">
{showTakenButton && canAdd && (
{showTakenButton && canAddIntake && (
<Button
variant="outline"
size="sm"
@@ -734,7 +736,7 @@ export function LedgerRowItem({
items in one menu, a delegate gets skip as its own control beside
take, which is the shape the card already uses. The owner keeps it
where it was. */}
{showTakenButton && canAdd && !canManageMedications && (
{showTakenButton && canAddIntake && !canManageMedications && (
<Button
variant="outline"
size="sm"
@@ -62,9 +62,10 @@ export function MedicationCardMenu({
// medication and opening its history editor are not. When nothing in the
// menu survives, the trigger goes with it rather than opening onto an empty
// sheet. The card itself is untouched: no wash, no badge, no colour change.
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddSideEffect = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const showSideEffect = onLogSideEffect != null && canAdd;
const showSideEffect = onLogSideEffect != null && canAddSideEffect;
if (!canManageMedications && !showSideEffect) return null;
return (
@@ -407,7 +407,8 @@ function MedicationTableRowItem({
const { intakeLoading, recordIntake } = useMedicationIntake({ medication });
// v1.36.x — marking a dose is admitted; a read-only delegate sees the row
// without its action pair.
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
// SAME batched compliance source as the cards.
const { data: compliance } = useMedicationComplianceSummary(medication.id);
@@ -685,7 +686,7 @@ function MedicationTableRowItem({
</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active && canAdd ? (
{medication.active && canAddIntake ? (
<div className="flex gap-1.5">
<Button
size="icon"
@@ -112,7 +112,8 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const { t } = useTranslations();
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddSideEffect = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const fmt = useFormatters();
const queryClient = useQueryClient();
@@ -250,7 +251,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
)}
{/* v1.36.x — noting a side effect is an admitted delegated write.
Removing one that is already noted is not. */}
{canAdd && (
{canAddSideEffect && (
<Button
size="sm"
variant="outline"
@@ -10,10 +10,10 @@
* Mutation checks, run:
* - return `canManage: true` for a WRITE record → "a delegate may not change
* what is already there" goes red.
* - return `canAdd: true` for a READ record → "a read-only delegate adds
* nothing" goes red.
* - make the `!active` branch return `canAdd: false` → "the caller's own
* record keeps everything" goes red.
* - answer `canWriteDomain` from `level` instead of the published list →
* "a read-only delegate adds nothing" and the scoped legs go red.
* - make the `!active` branch answer `canWriteDomain: false` → "the caller's
* own record keeps everything" goes red.
* - answer `canManageDomain` from `level === "manage"` instead of the list →
* "the vault is never manageable" goes red.
*/
@@ -76,7 +76,6 @@ describe("resolveRecordCapabilities", () => {
expect(own).toMatchObject({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
// No grant, so no level and no narrowing — not a fabricated "manage"
// over all eight sections, which a consumer would then have to tell
@@ -91,10 +90,12 @@ describe("resolveRecordCapabilities", () => {
}
expect(resolveRecordCapabilities(undefined)).toMatchObject({
inSharedRecord: false,
canAdd: true,
canManage: true,
recordKind: "self",
});
expect(resolveRecordCapabilities(undefined).canWriteDomain("labs")).toBe(
true,
);
});
it("refuses a malformed published block instead of treating it as own record", () => {
@@ -102,7 +103,6 @@ describe("resolveRecordCapabilities", () => {
expect(refused).toMatchObject({
inSharedRecord: true,
canWrite: false,
canAdd: false,
canManage: false,
level: null,
sections: [],
@@ -120,7 +120,6 @@ describe("resolveRecordCapabilities", () => {
expect(reader).toMatchObject({
inSharedRecord: true,
canWrite: false,
canAdd: false,
canManage: false,
level: "read",
sections: null,
@@ -139,7 +138,6 @@ describe("resolveRecordCapabilities", () => {
expect(writer).toMatchObject({
inSharedRecord: true,
canWrite: true,
canAdd: true,
canManage: false,
level: "write",
sections: null,
@@ -155,6 +153,26 @@ describe("resolveRecordCapabilities", () => {
}
});
it("a grant that can write nothing is still a WRITE grant, and offers nothing", () => {
// The reason the coarse answer is gone. `canWrite` is the LEVEL and has no
// scope term, so a WRITE grant scoped to the vault — which takes no
// delegated write at any level — answers true while every section answers
// false. A control gating on the level would paint an upload button that
// 403s at `grantCoversDomain`.
const vaultOnly = resolveRecordCapabilities({
...WRITABLE,
sections: ["documents"],
writableDomains: [],
manageableDomains: [],
});
expect(vaultOnly.canWrite).toBe(true);
for (const domain of SHARE_DOMAINS) {
expect(vaultOnly.canWriteDomain(domain), domain).toBe(false);
expect(vaultOnly.canManageDomain(domain), domain).toBe(false);
}
expect(vaultOnly.canManage).toBe(false);
});
it("carries the level and the sections through untouched", () => {
// The two facts v1.37.0 adds are bound, not interpreted. `sections` is
// the server's list in the server's order; `level` is the server's word.
@@ -177,7 +195,6 @@ describe("resolveRecordCapabilities", () => {
expect(manager).toMatchObject({
inSharedRecord: true,
canWrite: true,
canAdd: true,
canManage: true,
level: "manage",
sections: null,
@@ -219,7 +236,12 @@ describe("resolveRecordCapabilities", () => {
access: "write",
canWrite: false,
};
expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
for (const domain of SHARE_DOMAINS) {
expect(
resolveRecordCapabilities(disagreeing).canWriteDomain(domain),
domain,
).toBe(false);
}
// The same posture for the lists: a scoped WRITE grant is writable only
// where its list says so, not wherever its level would reach.
const scopedWriter: AccountAccessEntry = {
@@ -246,7 +268,7 @@ describe("resolveRecordCapabilities", () => {
* every delegable route will refuse, while `active` being null would otherwise
* make this hook answer "your own record, all controls". That combination
* paints an add button on a page whose every write is about to 403 — the exact
* failure `canAdd` exists to end.
* failure the per-section answers exist to end.
*/
describe("an unprovable record context withholds every control", () => {
it("holds when the raw selector names a record the grant no longer opens", () => {
@@ -254,7 +276,9 @@ describe("an unprovable record context withholds every control", () => {
recordContextIsUnproven({ epoch: 3, scope: "acct-owner" }, null),
).toBe(true);
const held = resolveRecordCapabilities(null, false, false, true);
expect(held.canAdd).toBe(false);
for (const domain of SHARE_DOMAINS) {
expect(held.canWriteDomain(domain), domain).toBe(false);
}
expect(held.canManage).toBe(false);
// REFUSED, not pending. `recordSessionPending` renders a bare spinner with
// no controls, which is right for a switch in flight and a wedge here: this
+13 -10
View File
@@ -43,9 +43,8 @@ const ALWAYS = () => true;
* server's table (`domain-write-support.ts`), published per grant as two
* lists on `accountAccess.active`. Four answers carry that:
*
* - `canAdd` — may this person add one of the admitted kinds.
* - `canWriteDomain(domain)` — is there a delegated write in this section
* the grant satisfies.
* the grant satisfies. The only question a create affordance asks.
* - `canManageDomain(domain)` — may this person change what is already
* there in this section, or add what a WRITE grant does not cover.
* - `canManage` — the coarse switch: is there ANY section this person may
@@ -53,7 +52,11 @@ const ALWAYS = () => true;
* because a control belongs to one section and the answer differs per
* section (the vault is read-only under every grant).
*
* A surface that offers an admitted create asks `canAdd`. A control that
* A surface that offers an admitted create asks `canWriteDomain` for its own
* section. There is deliberately no coarse "may this person add anything":
* `canWrite` is the grant's level with no scope term, so a WRITE grant scoped
* to `["labs"]` answers true and a weight form built on that answer 403s at
* `grantCoversDomain`. A control that
* edits, deletes or creates what WRITE does not cover asks `canManageDomain`
* for its own section. A control whose route resolves the caller rather than
* the record (settings, credentials, AI budget, chart preferences) asks
@@ -92,10 +95,13 @@ export interface RecordCapabilities {
accessRefused?: boolean;
/** Is this browser acting on somebody else's record right now. */
inSharedRecord: boolean;
/** The grant's resolved level for the record on screen. */
/**
* The grant's resolved level for the record on screen — LEVEL ONLY, with no
* scope term. A WRITE grant scoped to the document vault answers true here
* and can write nothing anywhere. Never gate a control on it; ask
* {@link canWriteDomain} for the control's own section.
*/
canWrite: boolean;
/** May the caller add an entry of a kind the delegation admits. */
canAdd: boolean;
/**
* May the caller change what exists somewhere in this record, or add what
* the delegation excludes. In one's own record, always. In a shared record,
@@ -166,7 +172,6 @@ export function resolveRecordCapabilities(
recordSessionPending: recordSessionPending || undefined,
inSharedRecord: true,
canWrite: false,
canAdd: false,
canManage: false,
canWriteDomain: NEVER,
canManageDomain: NEVER,
@@ -179,7 +184,6 @@ export function resolveRecordCapabilities(
return {
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
canWriteDomain: ALWAYS,
canManageDomain: ALWAYS,
@@ -201,7 +205,6 @@ export function resolveRecordCapabilities(
return {
inSharedRecord: true,
canWrite: active.canWrite,
canAdd: active.canWrite,
canManage: manageable.size > 0,
canWriteDomain: (domain) => writable.has(domain),
canManageDomain: (domain) => manageable.has(domain),
@@ -243,7 +246,7 @@ export function resolveRecordCapabilities(
* opens: every delegable route will refuse, while `active` being null makes
* this hook answer "your own record, all controls". That combination paints an
* add button on a page whose every write is about to 403 — the exact failure
* `canAdd` was introduced to end. So a disagreement holds instead.
* the per-section answers exist to end. So a disagreement holds instead.
*
* A null or absent `recordSession` is not a disagreement. It is the Bearer
* transport (no session row, no switch state) or a server image that predates
+14 -5
View File
@@ -96,11 +96,20 @@ export interface AccountAccessEntry {
*/
sections: ShareDomain[] | null;
/**
* May this caller ADD to that record. Resolved server-side. It does not by
* itself mean edit or delete — a WRITE grant adds and does nothing more, and
* that stayed true when a third level arrived. A MANAGE grant answers true
* here as well, and what it additionally admits is said by `level` rather
* than smuggled into this boolean.
* The grant's LEVEL, resolved server-side: is it at or above WRITE.
*
* It is not "may this caller add something", and reading it that way is how
* section-blind controls happen. There is no scope term in it: a WRITE grant
* scoped to the document vault answers `true` here with
* `writableDomains: []`, because the vault takes no delegated write at any
* level. Ask {@link AccountAccessEntry.writableDomains} for the section the
* control belongs to; this boolean answers only whether the grant is a
* reading one.
*
* It does not by itself mean edit or delete — a WRITE grant adds and does
* nothing more, and that stayed true when a third level arrived. A MANAGE
* grant answers true here as well, and what it additionally admits is said
* by `level` rather than smuggled into this boolean.
*/
canWrite: boolean;
/**