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

+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(