Ask what the record on screen allows before rendering a control

Account sharing shipped read-only, and everything that consulted the grant
lived in the application chrome: the nav bars, the banner, the switcher. No
feature surface asked. A delegate opened somebody else's record, saw the add
button on every page, filled the form, and was refused by a server that was
right to refuse. Nothing leaked; it taught people the product was broken.

This is the answer every mutation affordance asks for. Two booleans, because
a grant at WRITE admits a short closed list of verbs and nothing else:
`canAdd` for an entry of an admitted kind, `canManage` for changing what is
already there or adding what the list does not name. `canWrite` arrives
resolved from the server and is bound, never recomputed; what this decides is
only which class of control a resolved level covers.

The absent case is the permissive one. Failing closed would blank every add
button in the app for every account on every cold load, to spare a delegate
one frame between the reload and the account query landing.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 12:11:18 +02:00
1 parent 4f8fa25b51
commit 3e7127dca5
2 files changed
+209

No files matched your search

@@ -0,0 +1,85 @@
/**
* What a delegation lets somebody do, pinned as a table.
*
* The hook itself is one line over `useAuth`; the decision lives in
* `resolveRecordCapabilities`, which is pure and has exactly three inputs a
* session can be in. So the contract is enumerated here rather than inferred
* from a rendered page — and every affordance in the sweep binds one of these
* two booleans, which is what makes the sweep provable at all.
*
* 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.
*/
import { describe, expect, it } from "vitest";
import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities";
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
const READ_ONLY: AccountAccessEntry = {
accountId: "acct-owner",
username: "owner",
displayName: "Margarethe",
access: "read",
canWrite: false,
};
const WRITABLE: AccountAccessEntry = {
...READ_ONLY,
access: "write",
canWrite: true,
};
describe("resolveRecordCapabilities", () => {
it("the caller's own record keeps everything", () => {
// `null` is not "no access", it is "this is mine". Both an unswitched
// session and a payload from a server that has never heard of sharing
// land here, which is why the absent case must be the permissive one.
expect(resolveRecordCapabilities(null)).toEqual({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
});
expect(resolveRecordCapabilities(undefined)).toEqual(
resolveRecordCapabilities(null),
);
});
it("a read-only delegate adds nothing", () => {
expect(resolveRecordCapabilities(READ_ONLY)).toEqual({
inSharedRecord: true,
canWrite: false,
canAdd: false,
canManage: false,
});
});
it("a delegate may not change what is already there", () => {
// The asymmetry the whole design rests on: a WRITE grant adds, and that
// is all it does. Not even to an entry the delegate made a minute ago.
expect(resolveRecordCapabilities(WRITABLE)).toEqual({
inSharedRecord: true,
canWrite: true,
canAdd: true,
canManage: false,
});
});
it("binds the server's boolean rather than the grant's label", () => {
// `access` is descriptive; `canWrite` is the resolved decision. A row that
// says "write" while the server resolved false (lapsed, revoked mid-flight,
// a level this build does not honour) must read as read-only, because two
// programs deciding one person's access is how they end up disagreeing.
const disagreeing: AccountAccessEntry = {
...READ_ONLY,
access: "write",
canWrite: false,
};
expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
});
});
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import {
accountLabel,
type AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
/**
* What the person at the keyboard may do to the record on screen.
*
* ## Why this exists
*
* v1.36.0 shipped account sharing read-only, and everything that consulted
* the grant lived in the application chrome: the nav bars, the banner, the
* switcher. No feature surface asked. So a delegate opened `/measurements`
* inside somebody else's record, saw the add button, filled the form, and was
* refused by the server. Nothing leaked, because the refusal was right. It
* still taught them the product was broken. This hook is what every mutation
* affordance asks before it renders.
*
* ## The two answers, and why there are two
*
* A grant at WRITE admits a short, closed list of verbs: entering readings,
* lab results, allergies, family history, an illness entry, a biomarker, a
* tracked value, a side effect, a medication, and marking a dose taken. It
* admits nothing else. Editing, deleting, restoring, purging, importing and
* bulk-acting stay with the owner, including on an entry the delegate added
* themselves a minute ago, and so does every create the list does not name
* (a mood entry, a screener, a cycle day). Two booleans carry that:
*
* - `canAdd` — may this person add one of the admitted kinds.
* - `canManage` — may this person change what is already there, or add
* something the delegation does not cover. True only in one's own record.
*
* A surface that offers an admitted create asks `canAdd`. Everything else
* asks `canManage`. Neither is a permission decision made here: `canWrite`
* arrives resolved from the server on `accountAccess.active` and is bound,
* never recomputed (`account-access-view.ts:11-15`). What this file decides is
* only which class of affordance a resolved level covers, and that mapping is
* a property of the release, not of the caller.
*
* ## Absent, not disabled
*
* The consuming rule, stated once here because it belongs to the whole sweep:
* a control this returns `false` for does not render at all. A greyed-out
* button still says "this exists and does not work for you"; a control that is
* simply not there says "this is not part of what you were given", which is
* the truth. The banner already states the mode globally, so no per-control
* chip explains the absence — only an empty state a person would otherwise
* search a button in gets a sentence.
*/
export interface RecordCapabilities {
/** Is this browser acting on somebody else's record right now. */
inSharedRecord: boolean;
/** The grant's resolved level for the record on screen. */
canWrite: boolean;
/** May the caller add an entry of a kind the delegation admits. */
canAdd: boolean;
/** May the caller change what exists, or add what the delegation excludes. */
canManage: boolean;
}
/**
* The mapping, as a pure function of the record the session is inside.
*
* Exported so its contract can be pinned without a click: the three states a
* session can be in are enumerable, and the test enumerates them. `null` is
* one's own record, which is every capability.
*/
export function resolveRecordCapabilities(
active: AccountAccessEntry | null | undefined,
): RecordCapabilities {
if (!active) {
return {
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
};
}
return {
inSharedRecord: true,
canWrite: active.canWrite,
canAdd: active.canWrite,
canManage: false,
};
}
/**
* The record on screen, and what may be done to it.
*
* Reads the same resolved block the banner and the switcher read, so the
* chrome and the controls beneath it cannot disagree about what a delegate
* was given.
*
* Before `/api/auth/me` settles, `active` is absent and the answer is the
* caller's own record. That is deliberate rather than fail-closed: failing
* closed would blank every add button in the app for every account on every
* cold load, to spare a delegate one frame. The switch flow ends in a hard
* reload, so a delegate's first paint can show an add control for as long as
* the account query takes, and then it goes. A control that appears and
* withdraws is a much smaller lie than one that stays and 403s, and the
* banner lands from the same query in the same frame.
*/
export function useRecordCapabilities(): RecordCapabilities {
const { user } = useAuth();
return resolveRecordCapabilities(user?.accountAccess?.active);
}
/**
* The name of the record this browser is inside, or null in one's own.
*
* The delegate's receipt: a mutation that lands under a switch says which
* record it landed in, because "Saved" alone is the one confirmation a person
* acting for somebody else does not need. Kept beside the capabilities because
* it reads the same resolved entry and would otherwise be a fourth place that
* decides what to call somebody.
*/
export function useActiveRecordName(): string | null {
const { user } = useAuth();
const active = user?.accountAccess?.active ?? null;
return active ? accountLabel(active) : null;
}