fix(mood): the linked day read crosses sections, so it declares record

The route answered sleep minutes, steps, active energy, resting heart rate,
heart-rate variability and an illness day-log with its symptom keys, and it
declared `mind`. A delegate given only the mind section could walk arbitrary
dates through the one URL and read two sections the owner had declined to
share; no mood entry had to exist for any of those dates.

`record` is the convention for a read that crosses sections, and a scoped
grant never reaches one, so the scoped delegate loses the linked block rather
than receiving a filtered day. A block that silently dropped the sections a
grant does not cover would show a day that looks empty where the truth is that
it is none of the reader's business.

The reason is written at the declaration and in the frozen surface entry,
whose previous justification held only for whole-record grants.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 09:58:22 +02:00
1 parent 7b1a44e799
commit 88f83104e0
3 files changed
+276 -12

No files matched your search

+2 -2
View File
@@ -888,8 +888,8 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
why: "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.",
},
"app/api/mood/linked-context/route.ts": {
domain: "mind",
why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only and resolved per request. Every figure it returns is a figure a delegate can already read on the record's own surfaces; nothing here is a preference of the person, and nothing is written.",
domain: "record",
why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only. It is reached from a mood surface and it is NOT a mind read: the payload is sleep minutes, steps, active energy, resting heart rate, heart-rate variability and an illness day-log with its symptom keys. Declared `mind` it let a mood-only delegate walk arbitrary dates and read two sections the owner had declined to share, with no entry needing to exist for the date. Admitted on the whole-record grant only, like the other cross-section reads; a scoped grant is refused rather than served a filtered day.",
},
"app/api/mood-entries/[id]/route.ts": {
domain: "mind",
+28 -9
View File
@@ -1,16 +1,34 @@
/**
* `GET /api/mood/linked-context?date=YYYY-MM-DD` — what the modules that own
* sleep, activity, vitals and body symptoms already know about one day.
* `GET /api/mood/linked-context?date=YYYY-MM-DD&tz=…` — what the modules that
* own sleep, activity, vitals and body symptoms already know about one day.
*
* Keyed by the local day rather than by an entry id, deliberately: the capture
* sheet needs these figures BEFORE an entry exists, and a route that could
* only answer for a saved row would leave the one surface that most wants
* them unable to ask. The detail view passes the entry's own `date` and `tz`
* and gets the same answer.
* only answer for a saved row would leave the one surface that most wants them
* unable to ask. The capture sheet sends the browser's zone, which is the zone
* the day it is about to write will be anchored to; the edit dialog sends the
* entry's stored `tz`, so an entry logged in another country keeps its own day
* boundaries instead of being re-read under whichever zone the correction is
* being made from.
*
* Nothing here is stored on the mood row. Every figure is read from its owning
* module on each request, which is what makes a corrected sleep session show
* up on the mood entry with nothing to re-sync.
*
* **This route declares `record`, not `mind`, and that is the whole security
* decision on it.** Its payload is sleep minutes, steps, active energy,
* resting heart rate, heart-rate variability and an illness day-log with its
* symptom keys — the `measurements` and `illness` sections, reached from a
* mood surface. Declared as `mind` it was a hole with a mood-shaped label on
* it: a delegate given only the mind section could walk arbitrary dates
* through this one URL and read two sections the owner had declined to share,
* and no mood entry needed to exist for any of those dates. `record` is the
* convention for a read that crosses sections (`/api/dashboard/snapshot`,
* `/api/daily/digest` are the precedents) and a scoped grant never reaches
* one, so a scoped delegate loses the linked block entirely rather than
* receiving a filtered version of it. That is the honest answer: a block that
* silently dropped the sections a grant does not cover would show a day that
* looks empty where the truth is that it is none of the reader's business.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -27,14 +45,15 @@ export const dynamic = "force-dynamic";
const querySchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
// The zone the day is anchored to. Absent falls back to the account's own
// display timezone, which is what a fresh capture sheet means; the detail
// view passes the entry's stored `tz` so a row logged elsewhere keeps its
// own day boundaries.
// display timezone; the edit dialog passes the entry's stored `tz` so a row
// logged elsewhere keeps its own day boundaries.
tz: z.string().min(1).max(64).optional(),
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "mind");
// `record`: see the file header. A scoped grant is refused here by
// construction rather than served a narrowed payload.
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "mood");
if (!gate.enabled) return gate.response;