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(