Let a delegate read the dashboard they land on

Ten routes on `/` refused with `undeclared_mode`, which made the first page
of account sharing the only page in it that does not work. Each one now
declares a mode, per route rather than as a class.

Eight are record reads: the dashboard snapshot, the Today digest, the badge
grid, the Coach nudge signal and reminder ledger, the medication low-stock
thresholds, and the two presentation blobs. The old objection to admitting an
aggregate — a summary can carry a finding from a module the grant does not
cover — does not hold while a grant is whole-record and all-or-nothing. Every
admission site names that, so the day per-module scope lands the aggregates
are findable.

The layout routes split their verbs. Reading the owner's arrangement is right
and, for the dashboard, forced: the snapshot already carries the layout and
the client seeds the same cache cell from it, so an actor answer would put two
people's layouts in one key. Writing it is not a caregiver's job, so the PUT
and DELETE stay bare and refuse. Same on the medication list, where the stored
order names the owner's medication ids and means nothing anywhere else.

Two are actor surfaces: the UI language belongs to the person reading the
screen and must not be transplanted onto the record, and the feature-flag
matrix reads the settings singleton and no user row at all.

Also fixes a second instance of the delegate-data-under-owner-banner defect,
on the same page: the dashboard's client-side band fallback derived blood-
pressure targets and the weight range from `/api/auth/me`, which answers about
the caller, and painted them over the record's charts. Under a switch it now
derives nothing and waits for the snapshot's own bands.

The integration suite drives every admitted route with both accounts seeded,
proves the two answers differ, and then demands the owner's back.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 21:12:35 +02:00
1 parent a0fc404fb8
commit 508cc8a60a
17 files changed
+1025 -49

No files matched your search

+33 -3
View File
@@ -403,7 +403,7 @@ function exportedFunctionNames(rel: string): string[] {
* {@link DELEGABLE_WRITE_ROUTES} with their own leg below. Membership here is
* therefore necessary and not sufficient for a delegable write: a file can sit
* in this list and still refuse every mutation, which is what the other
* thirty-one do. Any write arm not named in the write literal is a diff two
* forty-one do. Any write arm not named in the write literal is a diff two
* lists have to agree on.
*/
const DELEGABLE_ROUTES: Record<string, string> = {
@@ -495,6 +495,28 @@ const DELEGABLE_ROUTES: Record<string, string> = {
"The account-wide intake list, scoped through the resolved user. Its cache cell keys on the same id it scopes to, and the canonical slot write on the POST arm is delegable.",
"app/api/medications/compliance/route.ts":
"Batched compliance across the record's cabinet. The one delegable read that spends a quota, so its rate-limit bucket keys on the ACTOR: a delegate must burn their own allowance rather than lock the owner out, and must not collect a fresh one by switching records.",
// The front door. Eight reads admitted together because `/` is the first
// page a delegate lands on and every one of them was refusing there, which
// made the entry point of the feature the worst surface in it. Six are
// aggregates or record state; two are presentation blobs whose write arms
// stay bare, and those two say so in their own line below.
"app/api/dashboard/snapshot/route.ts":
"The record's tile strip, and the widest aggregate in the product. Admitted on the whole-record grant: every input is a module the delegate may read directly, the briefing prose is lifted read-only off the owner's row rather than generated, and no credential or integration endpoint is in the payload. First route to re-examine if per-module scope ever lands.",
"app/api/daily/digest/route.ts":
"The record's Today hero. Same family and same argument as the snapshot, assembled from already-cached values with no provider on the path; the `insights` module gate now resolves against the RECORD, so a delegate gets the hero only where the owner switched it on.",
"app/api/gamification/achievements/route.ts":
"The record's badges, every one derived from the record's own history. Read-only in fact as well as in declaration since v1.35.3 moved the unlock INSERT onto the sweep job. Spans every module carrying a badge category, so it joins the snapshot and the digest in the per-module-scope re-examination.",
"app/api/insights/coach/nudge-status/route.ts":
"Whether the RECORD's Coach thread holds something unopened — a timestamp, a boolean and a conversation id. Not in tension with the Coach chat staying refused: chat spends the owner's AI budget and writes into their conversation, and this reads neither.",
"app/api/coach/reminders/route.ts":
"The record's reminder ledger. A 'remind me about X' note is a statement about the owner's own health, stored and encrypted on their row — the same shape as a mood note. The POST beside it keeps `requireAuth()`: writing into somebody's Coach memory puts a delegate's words in the voice the Coach reads back to the owner.",
"app/api/settings/reminder-thresholds/route.ts":
"The record's low-stock runway and reorder lead, which decide whether the OWNER's medication cards read 'low stock'. Projects exactly two integers out of `notificationPrefs`; the channels and endpoints in that object are unreachable from here and the route that serves it whole stays refused. No write arm exists.",
"app/api/dashboard/widgets/route.ts":
"The record's dashboard layout, read only. Settled by a fact rather than a preference: the snapshot already carries this layout and the client seeds the same cache cell from it, so an actor answer would put two people's arrangements in one key. The PUT and DELETE stay bare — a delegate adds to a record, never redecorates it.",
"app/api/medications/layout/route.ts":
"The record's medication-list presentation, read only. The stored `order` is a list of the OWNER's medication ids and unknown ids are dropped at apply time, so the caller's own order resolves to nothing against the owner's cabinet. The PUT and DELETE stay bare, and this is the refusal a delegate can actually walk into, since `/medications` is a shared destination.",
};
/**
@@ -553,15 +575,23 @@ const DELEGABLE_WRITE_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. Two of them
* endpoint, logout, the native refresh route, the locale setter. Four of them
* exist so far. The rest arrive as their own diffs here; naming them before
* they exist would freeze a guess.
*
* Note that "the caller's own thing" is the test, not "read-only": the locale
* setter WRITES, and writes to the caller's own row resolved from their own
* session. No grant is consulted because none is needed.
*/
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.",
"app/api/auth/me/locale/route.ts":
"The UI language belongs to the person reading the screen, never to the record on it. Under a record scope this would transplant a delegate's choice onto the owner's row and send the owner's cron mail in a language they may not read. It has to keep working rather than merely refuse safely: the switcher's mount-time backfill fires on every page load, including the shared dashboard.",
"app/api/feature-flags/route.ts":
"Reads the `AppSettings` singleton and no user row at all, so there is no record for a switch to substitute. It answers about the deployment, which for this purpose is the caller's side of the request; the shell gates the Coach launcher and the assistant surfaces on it, so a 403 here is a piece of chrome deciding it does not exist.",
};
/**
@@ -569,7 +599,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* stay 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 = 57;
const FROZEN_ENTRY_COUNT = 67;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -9,12 +9,17 @@
* - `getSession()` answers who is CALLING and deliberately never the record
* being acted on (`src/lib/auth/session.ts`), so the RSC read the
* delegate's own rows;
* - a NON-delegable route (`/api/dashboard/snapshot`, `/api/daily/digest`,
* `/api/dashboard/widgets`) refuses the refetch outright, so the seeded
* value is what stays on screen — under a banner naming somebody else;
* - a DELEGABLE route (`/api/measurements/series-batch`, `/api/medications`)
* answers with the owner's rows, so the same page paints two people at
* once until the refetch lands.
* - a NON-delegable route (`/api/user/thresholds`, `/api/mood/analytics`)
* refuses the refetch outright, so the seeded value is what stays on
* screen — under a banner naming somebody else;
* - a DELEGABLE route (`/api/dashboard/snapshot`, `/api/daily/digest`,
* `/api/dashboard/widgets`, `/api/measurements/series-batch`,
* `/api/medications`) answers with the owner's rows, so the same page
* paints two people at once until the refetch lands.
*
* The front-door reads moved from the first bullet to the second when `/` was
* made usable under a switch. That changed which ending a bad prefetch has,
* not whether it is one.
*
* These tests assert what reaches the cache, not which branch ran. Each one
* reads the dehydrated queries off the returned `<HydrationBoundary>` and
+17 -2
View File
@@ -22,7 +22,7 @@
import { NextRequest } from "next/server";
import { cookies } from "next/headers";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { locales, type Locale } from "@/lib/i18n/config";
@@ -37,7 +37,22 @@ function isLocale(value: unknown): value is Locale {
}
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// An actor surface, and the clearest case for the mode existing at all: the
// UI language belongs to the person reading the screen, never to the record
// on it. Under a record scope this would write the delegate's choice onto
// the OWNER's row and then send the owner's cron mail in a language they may
// not read — a preference silently transplanted between two people.
//
// It has to keep working while a switch is on rather than merely refuse
// safely: the language switcher fires a mount-time backfill on every page
// load, including the shared dashboard, and a 403 there breaks switching
// language for as long as the delegate stays inside the record.
//
// This is a WRITE on an actor surface, which is exactly right and worth
// naming, because the mode carries no method escalation: the row it updates
// is the caller's own, resolved from their own session, and no grant is
// consulted or needed.
const { user } = await requireActorAuth();
annotate({ action: { name: "user.locale.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
+19 -2
View File
@@ -17,7 +17,7 @@
* for the in-app tile). Omitted returns the non-terminal set (proposed / active
* / due / surfaced), soonest-due first.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -69,7 +69,17 @@ function toDto(row: ReminderRow, note: string) {
}
export const GET = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// The RECORD's reminder ledger. A "remind me about X" note is a statement
// about the owner's own health, stored on the owner's row and encrypted with
// it — the same shape as a mood note or a measurement note, both of which
// are already delegable reads. A delegate reading it is the caregiver case
// the feature exists for; an actor answer here would light the FAB's dot for
// the delegate's own ledger while the page shows another person's record,
// which is the failure mode this whole mode system exists to prevent.
//
// The module gate below resolves against the record, so the surface exists
// for a delegate only where the OWNER switched the Coach on.
const { user } = await requireRecordAuth("read");
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
@@ -121,6 +131,13 @@ export const GET = apiHandler(async (req: Request) => {
});
export const POST = apiHandler(async (req: Request) => {
// Deliberately still bare, so it refuses under a switch. Writing into
// somebody else's Coach memory is not adding a reading to their record: the
// note is what the Coach reads back to the OWNER in their own conversation,
// and putting a delegate's words in that voice is not a thing a caregiver
// grant should buy. A delegate who wants to record something writes it where
// it belongs — a measurement, a side effect, an allergy — all of which are
// delegable creates that file an audit row naming them.
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
+9 -2
View File
@@ -15,7 +15,7 @@
* standard 403 `module.disabled` envelope even over a Bearer token. The rail's
* data-tile inputs inherit their own module gates via the snapshot builder.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
@@ -24,7 +24,14 @@ import { loadDailyDigest } from "@/lib/daily/load-digest";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// The record's Today hero. Same family as the dashboard snapshot and
// admitted on the same argument: an aggregate over the record's own data,
// assembled from already-cached values with no provider on the path. The
// module gate below now runs against the RECORD, which is the behaviour that
// matters — a delegate gets the hero only where the owner switched insights
// on, never where the delegate did. Re-examine with the snapshot when
// per-module scope lands; a digest is a summary of several modules at once.
const { user } = await requireRecordAuth("read");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
+18 -2
View File
@@ -29,7 +29,7 @@
* read-only from `User.insightsCachedText`. The nightly
* `insight-pregenerate` cron keeps that cache warm.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
@@ -38,7 +38,23 @@ import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// The record's tile strip, and the reason `/` is a shared destination at
// all. A delegate who cannot read this reads an empty dashboard under a
// banner naming the person whose record they just opened.
//
// The aggregate objection — a summary can carry a finding from a module the
// grant does not cover — is void while a grant is whole-record and
// all-or-nothing: there is no module the delegate may not read directly.
// This is the WIDEST aggregate in the product, so when per-module scope
// lands it is the first route to re-examine, and the re-examination is about
// the builder's inputs, not about this line. What the payload does not carry
// is as much the point: no credential, no integration endpoint, no
// notification channel, and the briefing prose is lifted read-only off the
// owner's own row rather than generated, so no provider is reachable here.
//
// The cache cell keys on the resolved id, so the owner's snapshot lands in
// the owner's cell and a delegated read neither reads nor poisons their own.
const { user } = await requireRecordAuth("read");
annotate({ action: { name: "dashboard.snapshot" } });
const timings: Record<string, number> = {};
+33 -3
View File
@@ -5,7 +5,7 @@
* hasn't customized yet). PUT replaces the supplied layout fields atomically.
* DELETE resets web-controlled choices and preserves the other preferences.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
@@ -180,11 +180,28 @@ async function buildDashboardLayout(
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Read: the RECORD's layout. Write: refused — see the PUT and DELETE below.
//
// The split is the whole decision, and the read half is settled by a fact
// rather than by taste: the dashboard SNAPSHOT already carries this layout
// in its `layout` field, the client seeds `queryKeys.dashboardWidgets()`
// from it, and the snapshot is a record read. Two writers, one cache cell.
// If this route answered with the caller's own layout, that cell would hold
// the owner's arrangement one moment and the delegate's the next, which is
// the same-key-different-content poisoning the query-key factory exists to
// prevent — and it would decide which of the owner's tiles get rendered.
//
// The layout is also a statement about the record and not only about a
// person: which of THIS record's signals are worth a tile, which hero items
// matter, which comparison baseline reads correctly for it. A delegate
// opening somebody's dashboard should see it arranged the way its owner
// arranged it.
const { user } = await requireRecordAuth("read");
// 5-minute TTL per blueprint §5; the layout changes only when the user
// hits the Settings → Dashboard save button, which invalidates this
// cache via `invalidateUserDashboardWidgets()`.
// cache via `invalidateUserDashboardWidgets()`. Keyed on the resolved id, so
// a delegated read fills the owner's cell and never their own.
const layout = await cached(
caches.dashboardWidgets as ServerCache<DashboardLayoutWithToken>,
user.id,
@@ -195,6 +212,17 @@ export const GET = apiHandler(async () => {
});
export const PUT = apiHandler(async (request: NextRequest) => {
// Bare on purpose: this refuses while a switch is on, and the GET above does
// not. `requireRecordAuth` escalates every non-safe method to `"write"`, so
// declaring the module once would have handed any WRITE-capable grant the
// power to rearrange the owner's dashboard — and a delegate's job is to add
// to a record, never to redecorate somebody else's app. Nothing a caregiver
// needs to do requires moving another person's tiles.
//
// The visible cost is the comparison-baseline toggle on the dashboard hero,
// which persists through here and therefore 403s under a switch. That is the
// right refusal: the baseline is stored on the owner's row and a delegate
// flipping it would change what the owner sees on their own next visit.
const { user } = await requireAuth();
const { data: rawBody, error: jsonError } = await safeJson(request, {
@@ -411,6 +439,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
export const DELETE = apiHandler(async () => {
// Bare, for the reason spelled out on the PUT: resetting somebody else's
// dashboard to defaults is the loudest possible version of redecorating it.
const { user } = await requireAuth();
const { normalized, updatedAt } = await prisma.$transaction(
+16 -5
View File
@@ -19,9 +19,10 @@
* "error": null
* }
*
* - `requireAuth()` — any logged-in user. Per-request flag fetches
* from the iOS native client always arrive after auth, so the
* gate matches the rest of the read-only profile surface.
* - `requireActorAuth()` — any logged-in user, including one acting on
* somebody else's record. Per-request flag fetches from the iOS native
* client always arrive after auth, so the gate matches the rest of the
* read-only profile surface; the mode is argued at the call site.
* - Master kills every sub-flag in the resolver before the shape
* leaves the handler, so callers never have to compose
* `master && sub`.
@@ -35,13 +36,23 @@
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { getAssistantFlags } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (_request: NextRequest) => {
await requireAuth();
// An actor surface, and the easiest call in the set: the answer comes off
// the `AppSettings` singleton and reads no user row at all, so there is no
// record for a switch to substitute. It stays reachable while a switch is on
// because the shell needs it on every page — the Coach launcher and the
// assistant surfaces are gated on it, and a 403 here is a piece of chrome
// that decides it does not exist.
//
// Declared rather than left bare so the reasoning is recorded: it answers
// about the DEPLOYMENT, which for this purpose is the caller's side of the
// request, not the record's.
await requireActorAuth();
annotate({ action: { name: "feature-flags.read" } });
const assistant = await getAssistantFlags();
+17 -2
View File
@@ -1,4 +1,4 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
@@ -34,7 +34,22 @@ interface IosAchievement {
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// The record's badges. Every one of them is derived from the record's own
// history — the metrics, the module map and the unlock dates all come off
// the resolved account — so a delegate reads what the owner earned rather
// than a copy of their own tally under the owner's name. Nothing here is a
// preference of the person looking except the translation, and that resolves
// per request from the caller's locale below.
//
// Admitted as an aggregate on the whole-record grant. The badge grid spans
// every module that carries a badge category, so it joins the snapshot and
// the digest in the set to re-examine if per-module scope ever lands.
//
// Read-only in fact as well as in declaration: v1.35.3 moved the unlock
// INSERT onto the sweep job, so there is no write for a read grant to have
// to refuse. `requireRecordAuth("read")` would refuse a non-safe method
// anyway; this file exports no other verb.
const { user } = await requireRecordAuth("read");
// v1.18.0 — when the account has the achievements module turned off the
// whole gamification surface disappears: no badge evaluation, no unlock
@@ -22,13 +22,30 @@
* local seen-stamp keys on a stable value (kept for the existing client
* contract).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// The RECORD's unread signal, not the caller's. The tempting reading is that
// the FAB is the delegate's own chrome and should keep answering about the
// delegate's own Coach — and that is exactly the defect this feature already
// shipped once, in a different place: an actor answer paints the delegate's
// unread dot on a page whose banner names somebody else. The shell hides the
// FAB while a switch is on, but this route is in the refusal log precisely
// because the query fires before `/api/auth/me` has resolved and the shell
// knows to hide it, so "the client never renders it" is not a property to
// rest a data scope on.
//
// What crosses the wire is a timestamp, a boolean and a conversation id, all
// of the record's own Coach thread. The Coach CHAT stays refused under a
// switch and that is not in tension with this: chat spends the owner's AI
// budget and writes into their conversation, and reading whether the thread
// has something unopened does neither.
const { user } = await requireRecordAuth("read");
// Operator-level flag — an `AppSettings` singleton, unaffected by whose
// record is open.
await requireAssistantSurface("coach");
// Shared with the `/coach` RSC prefetch (`src/app/coach/page.tsx`) so both
+24 -5
View File
@@ -11,7 +11,7 @@
* lives on its own `User` column (`medication_list_layout_json`) per
* the per-surface-column convention.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
@@ -75,11 +75,21 @@ async function buildMedicationListLayout(
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Read: the RECORD's presentation. Write: refused — see the PUT and DELETE.
//
// Same split as `/api/dashboard/widgets`, and here the read half decides
// itself: `order` is a list of MEDICATION IDS, and unknown ids are dropped
// at apply time. The caller's own order names the caller's own medications,
// so serving it against the owner's cabinet would resolve to nothing and the
// owner's list would fall back to default order — a preference that cannot
// apply is not a preference, it is noise. `/medications` is a shared
// destination, so this is a list a delegate really does see.
const { user } = await requireRecordAuth("read");
// 5-minute TTL matches the dashboard-widgets / insights-layout
// caches; the blob changes only on a view toggle or an order save,
// which invalidates via `invalidateUserMedicationListLayout()`.
// 5-minute TTL matches the dashboard-widgets / insights-layout caches; the
// blob changes only on a view toggle or an order save, which invalidates via
// `invalidateUserMedicationListLayout()`. Keyed on the resolved id, so a
// delegated read fills the owner's cell and never their own.
const layout = await cached(
caches.medicationListLayout as ServerCache<MedicationListLayoutWithToken>,
user.id,
@@ -90,6 +100,13 @@ export const GET = apiHandler(async () => {
});
export const PUT = apiHandler(async (request: NextRequest) => {
// Bare on purpose, so it refuses under a switch while the GET does not. The
// reorder handle sits on `/medications`, which IS a shared destination, so
// this is the one refusal on the front-door set a delegate can actually walk
// into — and it is the correct one: the stored order is what the OWNER sees
// on their own next visit, and a helper tidying somebody else's cabinet into
// their preferred sequence is a change the owner never asked for and would
// have to undo by hand.
const { user } = await requireAuth();
const { data: rawBody, error: jsonError } = await safeJson(request, {
@@ -194,6 +211,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
export const DELETE = apiHandler(async () => {
// Bare, for the reason on the PUT: wiping somebody else's stored order is
// the same edit with a bigger blast radius.
const { user } = await requireAuth();
await prisma.user.update({
@@ -1,6 +1,6 @@
import { apiSuccess } from "@/lib/api-response";
import { getReminderThresholds } from "@/lib/app-settings";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
@@ -8,8 +8,26 @@ import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// The RECORD's thresholds, and the third of the presentation trio — but the
// one where "presentation" understates it. These two numbers decide whether
// a medication card reads "low stock" and when it says to reorder, and the
// cards they colour are the OWNER's. Answering with the caller's runway
// would put the delegate's idea of "running out" on somebody else's supply:
// a helper who keeps a fortnight's buffer would see a warning on a cabinet
// its owner considers comfortable, or worse, miss one on a cabinet its owner
// does not.
//
// What this route projects out of `notificationPrefs` is exactly two
// integers. That object also holds channels, endpoints and quiet hours, and
// none of it is reachable from here — the two fields are named individually
// below, and the route that serves the object whole stays refused.
//
// No write arm exists, so there is nothing to split: a delegate cannot move
// the owner's threshold, only read the one the owner set.
const { user } = await requireRecordAuth("read");
// Operator-level singleton (`lateMinutes` / `missedMinutes`) — the same for
// every account on the deployment, so the switch does not touch it.
const thresholds = await getReminderThresholds();
// v1.16.11 — the low-stock runway threshold rides along so every
+20 -9
View File
@@ -156,7 +156,7 @@ import {
import type { DataSummary } from "@/lib/analytics/trends";
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
import { buildDashboardBands } from "@/lib/dashboard/bands";
import { buildDashboardBands, viewerBandProfile } from "@/lib/dashboard/bands";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
import { toProfileSex } from "@/lib/profile/sex";
import { apiGet } from "@/lib/api/api-fetch";
@@ -742,17 +742,28 @@ export default function DashboardPageClient({
// `thresholdsData` is never fetched, so the client copy falls back to the
// height-derived band for the frames before `serverBands` lands — exactly
// as it already did for every other profile-derived number.
// v1.36.x — the profile this fallback reads is the CALLER's, and under a
// switch the caller is the delegate. `viewerBandProfile` is where that is
// decided and argued; the effect here is that a delegated visit draws no
// client-side band at all and waits for the snapshot's, which resolve
// against the record.
const inSharedRecord = user?.accountAccess?.active != null;
const clientBands = useMemo(
() =>
buildDashboardBands({
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
gender: toProfileSex(user?.gender),
heightCm: user?.heightCm ?? null,
weightTargetOverride: resolveWeightTargetOverride(
thresholdsData?.overrides ?? null,
buildDashboardBands(
viewerBandProfile(
{
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
gender: toProfileSex(user?.gender),
heightCm: user?.heightCm ?? null,
weightTargetOverride: resolveWeightTargetOverride(
thresholdsData?.overrides ?? null,
),
},
inSharedRecord,
),
}),
[user, thresholdsData],
),
[user, thresholdsData, inSharedRecord],
);
const bands = serverBands ?? clientBands;
const bpTargets = bands.bpTargets;
+6 -4
View File
@@ -68,10 +68,12 @@ function softTimeout<T>(ms: number): Promise<T | undefined> {
* answers null while the browser is acting on somebody else's record. Every
* read below scopes to `user`, and under a switch `user` is the DELEGATE —
* so the snapshot, the digest and the widget layout would seed one person's
* numbers under a banner naming another, on routes that then refuse the
* refetch that would have corrected them. The accessor's docblock carries
* the full argument, including why resolving the owner here instead is the
* worse answer.
* numbers under a banner naming another. Since those three became delegable
* reads the client's refetch does correct it, which makes the failure a
* flash of the wrong person's dashboard rather than a permanent one; that
* is a smaller bug and not a different one, so the accessor still steps
* aside here. Its docblock carries the full argument, including why
* resolving the owner here instead is the worse answer.
* - Fail-soft: no session, an active switch, a builder hiccup, or a DB blip
* renders the page exactly as before this wrapper existed — the client cells
* own the fetch. Each prefetch is independent; one failing never blocks the
@@ -0,0 +1,81 @@
/**
* Whose profile the dashboard's client-side band fallback is allowed to use.
*
* The fallback draws reference bands — blood-pressure targets, the weight
* range, every shaded chart zone — from the profile the page already holds,
* so that charts are not blank for the frames before the server-resolved
* bands arrive. That profile comes from `/api/auth/me`, an actor surface, so
* under an account switch it describes the DELEGATE while the charts underneath
* carry the OWNER's readings. A band is a claim about the person whose numbers
* it shades; borrowing one from the person looking is the same wrong-record
* paint the RSC prefetch already had to be taught to avoid, and it is quieter,
* because a shaded zone looks exactly as authoritative either way.
*
* These assert the emptiness has TEETH: the same profile that produces a real
* band unswitched must produce none switched, so an implementation that
* returned its input regardless fails on the second half of every case rather
* than on a shape check.
*
* Mutation checks, run:
* - `return profile;` unconditionally in `viewerBandProfile` → the three
* switched cases go red, printing the delegate's own BP target numbers as
* the value that reached the band.
*/
import { describe, expect, it } from "vitest";
import { buildDashboardBands, viewerBandProfile } from "@/lib/dashboard/bands";
/** A delegate with a complete profile — the worst case, not the empty one. */
const DELEGATE_PROFILE = {
dateOfBirth: new Date("1996-04-02T00:00:00Z"),
gender: "FEMALE" as const,
heightCm: 165,
weightTargetOverride: { min: 55, max: 62 },
};
describe("viewerBandProfile", () => {
it("hands the profile through untouched when nobody has switched", () => {
expect(viewerBandProfile(DELEGATE_PROFILE, false)).toEqual(
DELEGATE_PROFILE,
);
});
it("answers with no profile facts at all while acting on another record", () => {
expect(viewerBandProfile(DELEGATE_PROFILE, true)).toEqual({
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
});
});
});
describe("the bands that result", () => {
it("are real for the caller's own dashboard", () => {
// The evidence half. Without it, "no band under a switch" is satisfied by
// a profile that never produced one.
const own = buildDashboardBands(viewerBandProfile(DELEGATE_PROFILE, false));
expect(own.bpTargets).not.toBeNull();
expect(own.weightRange).not.toBeNull();
expect(own.weightBands).not.toBeNull();
});
it("carry none of the caller's profile onto a record they are acting on", () => {
const own = buildDashboardBands(viewerBandProfile(DELEGATE_PROFILE, false));
const shared = buildDashboardBands(
viewerBandProfile(DELEGATE_PROFILE, true),
);
expect(shared.bpTargets).toBeNull();
expect(shared.bpSysRange).toBeNull();
expect(shared.bpDiaRange).toBeNull();
expect(shared.weightRange).toBeNull();
expect(shared.weightBands).toBeNull();
// The pulse and body-fat bands have no null arm — they fall back to the
// published population range — so the claim there is that the numbers are
// the NEUTRAL ones and not the ones this caller's age and sex produce.
expect(shared.pulseDisplayRange).not.toEqual(own.pulseDisplayRange);
expect(shared.bodyFatRange).not.toEqual(own.bodyFatRange);
});
});
+42
View File
@@ -181,3 +181,45 @@ export function buildDashboardBands(profile: {
// The snapshot builder re-exports `buildDashboardBands` under the historic
// `buildTargetBands` name (see dashboard/snapshot.ts) so callers and the
// parity test keep working without a duplicate export living in this file.
/** The profile facts `buildDashboardBands` derives a band from. */
export interface BandProfile {
dateOfBirth: Date | null;
gender: ProfileSex;
heightCm: number | null;
weightTargetOverride: { min: number; max: number } | null;
}
/**
* Which profile the dashboard's CLIENT-side band fallback may compute from.
*
* The fallback exists because the server-resolved bands arrive with the
* snapshot and the charts have to draw before that: it recomputes the same
* numbers from the profile the page already holds. That profile comes from
* `/api/auth/me`, which is an actor surface — it answers about the person
* logged in and deliberately never about the record they are acting on. So
* while a switch is active it describes the DELEGATE, and drawing a band from
* it puts one person's reference ranges over another person's readings: a
* thirty-year-old helper's blood-pressure target shading an eighty-year-old's
* chart, which looks like a reading and is not one.
*
* Under a switch this therefore answers with nothing. The charts render
* unshaded until the snapshot's own bands land, and unshaded is honest —
* an absent band says nothing, while a borrowed one says something false.
*
* A pure function rather than three ternaries at the call site, because this
* is the whole of the decision and it should be possible to state it, test it,
* and break it in one place.
*/
export function viewerBandProfile(
profile: BandProfile,
inSharedRecord: boolean,
): BandProfile {
if (!inSharedRecord) return profile;
return {
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
};
}