Merge branch 'work/shared-front-door' into work/sharing-write-integration

# Conflicts:
#	src/__tests__/delegable-surface-guard.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 21:15:37 +02:00
commit a004d2358e
17 files changed
+1025 -49

No files matched your search

+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