mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'work/shared-front-door' into work/sharing-write-integration
# Conflicts: # src/__tests__/delegable-surface-guard.test.ts
This commit is contained in:
commit
a004d2358e
17 files changed
+1025
-49
No files matched your search
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user