feat(sharing): publish which records an account may open

`GET /api/auth/me` grows an `accountAccess` block: the accounts this caller
may act on, the one the browser is inside right now, and the booleans a UI
binds directly — `canSwitch`, and `canWrite` per account. Every value is
resolved server-side. A client that worked out what it may do from grant rows
would be a second program deciding one person's access to a health record, and
the two answer differently the first time an expiry or a revocation appears.

`canWrite` is false for every grant in v1 because invitations write READ only.
It is resolved through the grant state machine rather than hardcoded, so the
day a WRITE grant exists the answer changes without this file being edited.

The route becomes an actor surface. It has to be: the switcher, the banner
naming whose record is open, and the way back out all read this payload, and
under a bare `requireAuth()` the whole app shell would refuse the moment a
switch was on. Every field it returns is still the caller's own.

The active record is published as a full entry, not an id to look up. A banner
that had to join two fields could render unnamed the moment they disagreed;
here the active entry is one of the listed ones, selected by the pass that
built them. A stamp left behind by a grant that lapsed reads as not switched —
the same answer the resolver gives the next delegated request.

Not published: the other account's avatar. Those bytes are owner-scoped, so a
URL here would resolve to a refusal and paint a broken image. Clients keep the
initials fallback.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 14:25:55 +02:00
1 parent 6a096a75eb
commit 562123d4d1
6 files changed
+562 -32

No files matched your search

@@ -83,6 +83,16 @@ describe("the acting-account carrier is read in one place", () => {
// themselves call these two functions and never name the column, which is
// what keeps this list from growing one entry per surface.
"lib/sharing/acting-session.ts",
// v1.36.0 — the only thing that PUBLISHES it, and it publishes the stamp
// only after re-deciding it. The account payload has to say which record
// the browser is inside so the banner can name a person, but the value it
// prints is not the column: an entry reaches `active` solely by surviving
// the same live-grant pass that builds the switcher list, so a stamp left
// behind by a lapsed grant reads as "not switched" — the same answer the
// resolver will independently give the next delegated request. Takes the
// whole auth context for exactly this reason: had it taken the id, the
// route would name the column too, and the list would grow per surface.
"lib/sharing/account-access.ts",
].sort();
it("no other file reads or writes the carrier column", () => {
@@ -252,13 +252,15 @@ const DELEGABLE_ROUTES: Record<string, string> = {};
*
* The intended members are the surfaces a switched session needs in order to
* stay usable and to get back out: the account bootstrap payload, the switch
* endpoint, logout, the native refresh route, the locale read. One of them
* exists so far. The rest arrive as their own diffs here; naming them before
* endpoint, logout, the native refresh route, the locale read. Two of them
* exist so far. The rest arrive as their own diffs here; naming them before
* they exist would freeze a guess.
*/
const ACTOR_ROUTES: Record<string, string> = {
"app/api/account/switch/route.ts":
"The way back out. Every other route refuses under a switch, so if this one did too a browser could enter a record and never leave it. It reads and writes exactly one row — the caller's own session — renders nothing of the owner's, and grants nothing: the account it stamps is validated against a live grant first, and the stamp is re-checked on every request after.",
"app/api/auth/me/route.ts":
"The app shell reads it on every boot, including while a switch is on: the switcher, the banner naming whose record is open, and the route back out all bind this payload. Every field it returns is the caller's own — their preferences, their modules, their identity — and the one field about the switch says only which records they may open and which they are inside. It reads no row of the owner's, and its `accountAccess` block grants nothing: the resolver re-checks the grant on every delegated request regardless of what this payload said a moment ago.",
};
/**
@@ -266,7 +268,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* a formality by accident: every addition has to be counted here as well as
* listed above, which is one more place a careless admission has to pass.
*/
const FROZEN_ENTRY_COUNT = 1;
const FROZEN_ENTRY_COUNT = 2;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+60 -29
View File
@@ -1,5 +1,23 @@
/**
* `GET /api/auth/me` — the account payload, and the one surface that keeps
* answering while the browser is acting on somebody else's record.
*
* v1.36.0 made it an ACTOR surface (`requireActorAuth`). It has to be: the
* switcher, the banner naming whose record is open, and the way back out all
* read this payload, and all three are about the person at the keyboard, not
* the record they are looking at. Under a bare `requireAuth()` the whole app
* shell would 403 the moment a switch was on.
*
* So every field below is still the CALLER's own — their preferences, their
* modules, their identity — and the one field about the switch,
* `accountAccess`, says so explicitly. Nothing here ever renders the owner's
* data; a delegate reading their own display preferences while inside
* somebody else's record is the correct behaviour and the reason the mode
* exists (design §4: locale, theme and layout belong to the person, not the
* record).
*/
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
@@ -12,11 +30,13 @@ import {
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const auth = await requireActorAuth();
const { user } = auth;
annotate({ action: { name: "auth.me" } });
@@ -27,33 +47,38 @@ export const GET = apiHandler(async () => {
// cycle-tracking gate; no row is forced, a NULL toggle derives from
// gender) is a Postgres round-trip. Running them sequentially added
// the cookie hop to every /me — and /me sits on every app boot path.
const [, , cycleProfile, modules, moduleAvailability] = await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(user.id),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
getOperatorModuleAvailability(),
]);
const [, , cycleProfile, modules, moduleAvailability, accountAccess] =
await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(user.id),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
getOperatorModuleAvailability(),
// v1.36.0 — which records this caller may open, which one they are
// inside, and what they may do there. Every value resolved server-side;
// see the module docblock for why none of it is left to the client.
resolveAccountAccess(auth),
]);
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
@@ -142,5 +167,11 @@ export const GET = apiHandler(async () => {
// this to show a "disabled server-wide" read-only row; everywhere else
// the already-AND-ed `modules` map is the single gate to read.
moduleAvailability,
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
// `active` is the record this session is inside (null when it is in its
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
// directly. Always present — an account with no grants gets an empty list,
// not a missing field.
accountAccess,
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* v1.36.0 — the shape of the sharing block, and nothing else.
*
* Types only, with no imports at all, because both ends read them: the route
* that builds the block (`account-access.ts`, which talks to Postgres) and the
* client that renders it. Declaring them beside the server code would put a
* module that imports Prisma on the client's import graph — erased at compile
* today, one careless value export away from being bundled tomorrow. The
* boundary is cheaper to keep than to rediscover.
*
* What these types are FOR is stated once here and holds everywhere they are
* used: they carry resolved answers. `canWrite` and `canSwitch` are decisions
* the server has already made; a client that recomputed either from the rest
* of the payload would be the second program deciding one person's access.
*/
/** One account this caller may act on. */
export interface AccountAccessEntry {
/** The account whose record it is — the value `POST /api/account/switch` takes. */
accountId: string;
username: string;
displayName: string | null;
/** The grant's level. `"read"` throughout v1. */
access: "read" | "write";
/** May this caller change that record. Resolved server-side; `false` in v1. */
canWrite: boolean;
}
/** The `accountAccess` block on `GET /api/auth/me`. */
export interface AccountAccess {
/** Every account this caller may open, newest grant first. */
accounts: AccountAccessEntry[];
/**
* The record this browser is inside right now, resolved to a full entry —
* not an id the client has to look up. Null when it is in its own record,
* and always null on the Bearer transport.
*/
active: AccountAccessEntry | null;
/** Is there anywhere to switch to. */
canSwitch: boolean;
}
/**
* The name to put in front of a person, given what the payload published.
*
* `displayName` when the account set one, the username otherwise. This is a
* presentation fallback and not a derivation of anything the server decided —
* both values are published, and which of the two reads better is the client's
* business. Kept here so the banner, the switcher and the sharing panel cannot
* disagree about what to call somebody.
*/
export function accountLabel(entry: {
displayName: string | null;
username: string;
}): string {
const named = entry.displayName?.trim();
return named && named.length > 0 ? named : entry.username;
}
+97
View File
@@ -0,0 +1,97 @@
/**
* v1.36.0 — what a caller may do with account sharing, resolved server-side.
*
* This module answers three questions for one caller, once per `/api/auth/me`:
* which records may I open, which one am I inside right now, and what may I do
* in it. Every answer is a VALUE, not an input to a client-side calculation.
*
* That is the whole design of this file and it is worth stating plainly,
* because the tempting alternative is one line shorter: publish the grant rows
* and let the client work out what they mean. Then two programs decide one
* person's access — the resolver in `src/lib/api-handler.ts`, and whichever
* client is being read today — and they answer differently the first time an
* expiry, a revocation or a second access level appears. The client renders
* `canSwitch` and `canWrite`; it never computes them, and there is nothing in
* the payload it could compute them from.
*
* `canWrite` is `false` for every entry in v1, because `inviteGrant` writes
* READ and only READ. It is resolved here through {@link grantAllows} against
* the real row rather than hardcoded, so the day a WRITE grant exists the
* answer changes without this file being edited — and, more importantly, so
* that nobody has to remember that it should.
*
* What is deliberately NOT published: the other account's avatar. The avatar
* bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the
* caller's own), so a URL here would resolve to a 403 and paint a broken
* image. Opening that route to a delegate is a classification decision about
* a route, not a decision the account payload gets to make on the side.
* Clients paint the initials fallback they already have.
*/
import { prisma } from "@/lib/db";
import type {
AccountAccess,
AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
/**
* Resolve the sharing block for one caller.
*
* The block is always present, even for an account with no grants in either
* direction: an empty `accounts` array with `canSwitch: false` says "nobody has
* shared anything with you", which is a different statement from a missing
* field (which says "this server does not know about sharing"). Absence reads
* as absence.
*
* Takes the whole auth context rather than a pair of ids, so the carrier column
* is named here and in no route file — the boundary guard in
* `src/__tests__/acting-account-boundary-guard.test.ts` keeps that list short
* on purpose, and a field plucked out at every call site is how such a list
* grows one entry per surface.
*
* The session's stamp is deliberately NOT trusted to mean access: an entry
* appears in `active` only if the same pass that
* built `accounts` admitted it. So a stamp left behind by a grant that lapsed
* between two requests reads as "not switched" here, which is exactly what the
* request resolver will independently decide about the next delegated read.
* The client's job on seeing that mismatch is to clear the stamp, not to
* reason about it.
*/
export async function resolveAccountAccess(auth: {
user: { id: string };
session: { actingAsUserId?: string | null };
}): Promise<AccountAccess> {
const actorId = auth.user.id;
const stamped = auth.session.actingAsUserId ?? null;
const now = new Date();
// Narrow in SQL on the one condition the partial unique index makes cheap,
// and let the state machine decide the rest — the same split
// `findActiveGrant` uses, and for the same reason: a SQL predicate spelling
// out "accepted and not expired" beside a TypeScript one is two places
// deciding one question.
const rows = await prisma.accountGrant.findMany({
where: { granteeId: actorId, revokedAt: null },
orderBy: { createdAt: "desc" },
include: {
grantor: { select: { id: true, username: true, displayName: true } },
},
});
const accounts: AccountAccessEntry[] = rows
.filter((grant) => isGrantActive(grant, now))
.map((grant) => ({
accountId: grant.grantor.id,
username: grant.grantor.username,
displayName: grant.grantor.displayName,
access: grant.access === "WRITE" ? "write" : "read",
canWrite: grantAllows(grant, "write", now),
}));
const active =
stamped === null
? null
: (accounts.find((a) => a.accountId === stamped) ?? null);
return { accounts, active, canSwitch: accounts.length > 0 };
}