From 88f83104e050827144390119495f287d15757daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 09:58:22 +0200 Subject: [PATCH] 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. --- src/__tests__/sharing-surface-guard.test.ts | 4 +- src/app/api/mood/linked-context/route.ts | 37 ++- .../integration/mood-context-linkage.test.ts | 247 +++++++++++++++++- 3 files changed, 276 insertions(+), 12 deletions(-) diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index b8859eadc..27a40aa5a 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -888,8 +888,8 @@ const DELEGABLE_ROUTES: Record = { 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", diff --git a/src/app/api/mood/linked-context/route.ts b/src/app/api/mood/linked-context/route.ts index ff038fc04..97e13d4c0 100644 --- a/src/app/api/mood/linked-context/route.ts +++ b/src/app/api/mood/linked-context/route.ts @@ -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; diff --git a/tests/integration/mood-context-linkage.test.ts b/tests/integration/mood-context-linkage.test.ts index 98313b0be..13db69e77 100644 --- a/tests/integration/mood-context-linkage.test.ts +++ b/tests/integration/mood-context-linkage.test.ts @@ -19,7 +19,7 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; const TEST_USER_ID = "user-mood-linkage"; const DAY = "2026-06-11"; @@ -285,6 +285,251 @@ describe("mood context linkage (real Postgres)", () => { }); }); + it("sums one source per day, not both, when two report the same day", async () => { + // Two sources reporting the same day is the ordinary case for anyone + // syncing a phone and a watch, and summing both doubles the number on the + // mood sheet. The repo's cure is the canonical-source picker every other + // steps/energy read already runs through. + await getPrismaClient().measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 6000, + unit: "steps", + measuredAt: new Date("2026-06-11T08:00:00.000Z"), + source: "APPLE_HEALTH", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 5900, + unit: "steps", + measuredAt: new Date("2026-06-11T09:00:00.000Z"), + source: "FITBIT", + }, + { + userId: TEST_USER_ID, + type: "ACTIVE_ENERGY_BURNED", + value: 500, + unit: "kcal", + measuredAt: new Date("2026-06-11T08:00:00.000Z"), + source: "APPLE_HEALTH", + }, + { + userId: TEST_USER_ID, + type: "ACTIVE_ENERGY_BURNED", + value: 480, + unit: "kcal", + measuredAt: new Date("2026-06-11T09:00:00.000Z"), + source: "FITBIT", + }, + ], + }); + + const linked = await readLinked(); + // One stream, whichever the ladder picks — never the two added together. + expect([6000, 5900]).toContain(linked.activity.steps?.value); + expect([500, 480]).toContain(linked.activity.activeEnergy?.value); + expect(linked.activity.steps?.value).not.toBe(11900); + expect(linked.activity.activeEnergy?.value).not.toBe(980); + }); + + it("takes one source's vital for the day rather than whichever row is last", async () => { + // The default resting-heart-rate ladder ranks FITBIT above APPLE_HEALTH, + // and the fixture puts the LOWER-ranked source last in the day on purpose: + // a source-blind "latest of the day" answers 71, the canonical stream + // answers 62. Without that ordering the two rules agree and the case + // proves nothing. + await getPrismaClient().measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "RESTING_HEART_RATE", + value: 60, + unit: "bpm", + measuredAt: new Date("2026-06-11T07:00:00.000Z"), + source: "FITBIT", + }, + { + userId: TEST_USER_ID, + type: "RESTING_HEART_RATE", + value: 62, + unit: "bpm", + measuredAt: new Date("2026-06-11T12:00:00.000Z"), + source: "FITBIT", + }, + { + userId: TEST_USER_ID, + type: "RESTING_HEART_RATE", + value: 71, + unit: "bpm", + // 21:00 Berlin, deliberately still inside the same local day: at + // 22:00 UTC in June this row would belong to the NEXT Berlin day and + // the case would pass without proving anything. + measuredAt: new Date("2026-06-11T19:00:00.000Z"), + source: "APPLE_HEALTH", + }, + ], + }); + + const linked = await readLinked(); + // The picked source's latest, not the day's latest. + expect(linked.vitals.restingHeartRate).toEqual({ + present: true, + value: 62, + unit: "bpm", + }); + }); + + it("keeps ambient movement available when the workouts module is off", async () => { + // `workouts` gates workout SESSIONS. Steps and active energy are ambient + // movement and are deliberately unscoped — the module ownership table says + // so, and gating them here made the mood sheet stricter than every other + // surface in the product. + await getPrismaClient().measurement.create({ + data: { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 3000, + unit: "steps", + measuredAt: new Date("2026-06-11T08:00:00.000Z"), + source: "MANUAL", + }, + }); + await getPrismaClient().user.update({ + where: { id: TEST_USER_ID }, + data: { modulePreferencesJson: { workouts: false } }, + }); + + const linked = await readLinked(); + expect(linked.activity.available).toBe(true); + expect(linked.activity.steps).toEqual({ + present: true, + value: 3000, + unit: "steps", + }); + }); + + it("blanks the vitals block when the recovery module is off", async () => { + // Resting heart rate and heart-rate variability are owned by `recovery`, + // which the ownership table has said since v1.30.22. The block claimed to + // be core and answered them regardless, which made the release note's + // "a module you have switched off leaves its block out entirely" false. + await getPrismaClient().measurement.create({ + data: { + userId: TEST_USER_ID, + type: "RESTING_HEART_RATE", + value: 58, + unit: "bpm", + measuredAt: new Date("2026-06-11T07:00:00.000Z"), + source: "MANUAL", + }, + }); + expect((await readLinked()).vitals.available).toBe(true); + + await getPrismaClient().user.update({ + where: { id: TEST_USER_ID }, + data: { modulePreferencesJson: { recovery: false } }, + }); + + const linked = await readLinked(); + expect(linked.vitals).toEqual({ + available: false, + reason: "module-disabled", + }); + expect(linked.vitals).not.toHaveProperty("restingHeartRate"); + }); + + it("refuses a delegate whose grant does not open the whole record", async () => { + // The finding this case exists for: the route reads sleep, activity, + // vitals and the illness journal, and it used to declare `mind`. A + // delegate given the mind section alone could therefore walk arbitrary + // dates and read four other sections of the owner's record — no mood + // entry even had to exist for the date. + // + // The convention for a read that crosses sections is `record`, and a + // scoped grant never reaches one. So the scoped delegate loses the linked + // block entirely, which is the honest answer rather than a filtered one. + await getPrismaClient().measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 7777, + unit: "steps", + measuredAt: new Date("2026-06-11T08:00:00.000Z"), + source: "MANUAL", + }, + { + userId: TEST_USER_ID, + type: "RESTING_HEART_RATE", + value: 55, + unit: "bpm", + measuredAt: new Date("2026-06-11T07:00:00.000Z"), + source: "MANUAL", + }, + ], + }); + const episode = await getPrismaClient().illnessEpisode.create({ + data: { + userId: TEST_USER_ID, + label: "cold", + type: "INFECTION", + onsetAt: new Date("2026-06-10T08:00:00.000Z"), + }, + }); + await getPrismaClient().illnessDayLog.create({ + data: { + userId: TEST_USER_ID, + episodeId: episode.id, + date: DAY, + functionalImpact: 3, + }, + }); + + const delegate = await getPrismaClient().user.create({ + data: { + id: "user-mood-linkage-delegate", + username: "mood-linkage-delegate", + email: "mood-linkage-delegate@example.test", + timezone: "Europe/Berlin", + }, + }); + await getPrismaClient().accountGrant.create({ + data: { + grantorId: TEST_USER_ID, + granteeId: delegate.id, + access: "READ", + acceptedAt: new Date(), + // Only the mind section. Everything this route reads is outside it. + scopeJson: ["mind"] as never, + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: delegate.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + await switchSessionTo(session.id, TEST_USER_ID); + + const { GET } = await import("@/app/api/mood/linked-context/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/mood/linked-context?date=${DAY}&tz=Europe%2FBerlin`, + ), + ); + expect(res.status).toBe(403); + const raw = await res.text(); + // Asserted on the bytes, not on a parsed field: the point is that not one + // of the owner's figures reached the response. + expect(raw).not.toContain("7777"); + expect(raw).not.toContain("55"); + expect(raw).not.toContain("functionalImpact"); + }); + it("refuses a malformed day", async () => { const { GET } = await import("@/app/api/mood/linked-context/route"); const res = await GET(