mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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` —
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user