diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9c6ed935d..fa165cf0b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23413,8 +23413,9 @@ paths: description: "Everything on one local calendar day: what ran through it (medications, an illness, a cycle phase, a trip), the readings in its window with the person's usual range, what happened on it, and deterministic notable observations as keys with parameters. Sections of a switched-off module are left out; sections the caller's - grant does not cover are named in `sections`. Read live from the record, never from the UTC rollups. A date that - is not a real calendar date answers 422." + grant does not cover are named in `sections`. Life events and the environment are the owner's only: for a + delegate at any level they are named `not_shared`. Read live from the record, never from the UTC rollups. A date + that is not a real calendar date answers 422." parameters: - in: path name: date @@ -23441,7 +23442,8 @@ paths: summary: The record over the years description: Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of - a switched-off module never are. Requires the opt-in `timeline` module. + a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is + never sent to a delegate. Requires the opt-in `timeline` module. parameters: - in: query name: zoom @@ -23503,8 +23505,8 @@ paths: - Records summary: What the timeline can show description: "One row per lane: carries, thin or empty, a count, a detail as a message key with parameters, and the gaps - with one in-app link each. The verdict is one of two words, never a score. Requires the opt-in `timeline` - module." + with one in-app link each. The verdict is one of two words, never a score. The `life` lane is reported to the + owner only. Requires the opt-in `timeline` module." responses: "200": description: The readiness inventory. @@ -23521,8 +23523,8 @@ paths: tags: - Records summary: List life events - description: The record's life events, oldest first; soft-deleted ones are excluded. Title and note are decrypted for - the caller. Part of the `profile` sharing domain. Life events are never sent to a model. + description: "The account's own life events, oldest first; soft-deleted ones are excluded. Title and note are decrypted + for the caller. Owner-only: no delegate reads them at any share level. Life events are never sent to a model." responses: "200": description: The life events. @@ -23531,7 +23533,20 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": *a11 + "403": &a68 + description: >- + Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, + and not a guardian acting for a managed profile. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: @@ -23540,7 +23555,8 @@ paths: summary: Add a life event description: "A dated anchor in the person's life. `precision` says how much of the date is known and applies to both ends: at MONTH a date is the first of its month, at YEAR the first of its year. `endDate`, when given, is not - before `startDate`. Title (1-120 characters) and note (up to 2000) are encrypted at rest." + before `startDate`. Title (1-120 characters) and note (up to 2000) are encrypted at rest. Owner-only, like every + life-event route." parameters: - *a6 requestBody: @@ -23557,7 +23573,7 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a11 + "403": *a68 "409": *a7 "422": *a4 "429": *a12 @@ -23588,9 +23604,9 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a11 - "404": &a68 - description: No such life event on this record. + "403": *a68 + "404": &a69 + description: No such life event on this account. content: application/json: schema: @@ -23616,8 +23632,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a11 - "404": *a68 + "403": *a68 + "404": *a69 "422": *a4 "429": *a5 /api/bugreport: diff --git a/messages/de.json b/messages/de.json index 95e994717..e6ed49f5a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11881,7 +11881,7 @@ }, "profile": { "label": "Gesundheitliche Vorgeschichte", - "detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil, Arztbesuche und Praxen, deine Impfungen sowie deine Lebensereignisse." + "detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil, Arztbesuche und Praxen sowie deine Impfungen." }, "illness": { "label": "Krankheiten", diff --git a/messages/en.json b/messages/en.json index d85f09645..80768ae02 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11881,7 +11881,7 @@ }, "profile": { "label": "Health background", - "detail": "Allergies, family history, the facts in your health profile, your visits and practices, your vaccination record and your life events." + "detail": "Allergies, family history, the facts in your health profile, your visits and practices, and your vaccination record." }, "illness": { "label": "Illness", diff --git a/messages/es.json b/messages/es.json index dd6e662a1..850b1a3d5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -11881,7 +11881,7 @@ }, "profile": { "label": "Antecedentes de salud", - "detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud, tus visitas médicas y consultas, tu cartilla de vacunación y tus acontecimientos vitales." + "detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud, tus visitas médicas y consultas, y tu cartilla de vacunación." }, "illness": { "label": "Enfermedades", diff --git a/messages/fr.json b/messages/fr.json index adb6dca04..bf868b356 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -11881,7 +11881,7 @@ }, "profile": { "label": "Antécédents de santé", - "detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, vos consultations et cabinets, votre carnet de vaccination ainsi que vos événements de vie." + "detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, vos consultations et cabinets ainsi que votre carnet de vaccination." }, "illness": { "label": "Maladies", diff --git a/messages/it.json b/messages/it.json index 07746fed7..027d66121 100644 --- a/messages/it.json +++ b/messages/it.json @@ -11881,7 +11881,7 @@ }, "profile": { "label": "Anamnesi", - "detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario, le tue visite mediche e studi, il tuo libretto vaccinale e i tuoi eventi di vita." + "detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario, le tue visite mediche e studi e il tuo libretto vaccinale." }, "illness": { "label": "Malattie", diff --git a/messages/ko.json b/messages/ko.json index 04de60d80..876e78ced 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11881,7 +11881,7 @@ }, "profile": { "label": "건강 배경", - "detail": "알레르기, 가족력, 건강 프로필의 항목들, 진료와 병원, 예방접종 기록, 삶의 사건이에요." + "detail": "알레르기, 가족력, 건강 프로필의 항목들, 진료와 병원, 예방접종 기록이에요." }, "illness": { "label": "아픈 날", diff --git a/messages/pl.json b/messages/pl.json index 1a6857b27..3686c9349 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -11881,7 +11881,7 @@ }, "profile": { "label": "Historia zdrowia", - "detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego, wizyty lekarskie i gabinety, Twoja historia szczepień oraz Twoje wydarzenia życiowe." + "detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego, wizyty lekarskie i gabinety oraz Twoja historia szczepień." }, "illness": { "label": "Choroby", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 46ec7e4db..4685e0d45 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1045,14 +1045,6 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "Counts per timeline lane with an in-app link per gap. Counts only, no record content; scoped to the resolved user and module-gated like the timeline.", }, - "app/api/life-events/route.ts": { - domain: "profile", - why: "The record's life events, a section of health background beside allergies and visits; the consent copy for `profile` names them. The GET reads rows scoped `userId: user.id` and decrypts title and note for the resolved session; the create is a MANAGE verb (see the manage literal).", - }, - "app/api/life-events/[id]/route.ts": { - domain: "profile", - why: "One life event of the record, fetch-then-guard against the resolved user. Reached only through its MANAGE arms; an event another record holds is a 404 like any foreign id.", - }, "app/api/workouts/route.ts": { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", @@ -1660,16 +1652,6 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C4"], why: "Correcting and removing one vaccine definition. The removal soft-deletes it and lets go of the doses that named it, each keeping a name, so no dose leaves the record; the audit row names the definition and how many doses it let go. C4 on the edit: the antigens, the series length and the booster interval are filed before and after, and a rename is named, never quoted. Neither verb re-runs the booster satisfy matcher, so a delegate cannot move a booster's due date by correcting a definition.", }, - "app/api/life-events/route.ts": { - domain: "profile", - conditions: [], - why: "Recording a life event. The person's own anchor, so a guardian's act on a managed profile rather than a helper's; additive, rate-limited on the actor, audited with the category and precision and never the title.", - }, - "app/api/life-events/[id]/route.ts": { - domain: "profile", - conditions: ["C4"], - why: "Correcting and removing a life event. The removal soft-deletes and the audit row names the event's category and date; C4 on the edit: the dates, category and precision are filed before and after, and a changed title or note is named, never quoted.", - }, "app/api/illness/episodes/[id]/resolve/route.ts": { domain: "illness", conditions: [], @@ -2093,8 +2075,13 @@ const ACTOR_ROUTES: Record = { * * v1.42 -- the document vault's presentation read joins the record list * beside the rest of the vault; its PUT stays bare. 255 -> 256. + * + * v1.42 -- life events leave every list: they are owner-only in this + * release (no share level was worded for them), so both modules take a bare + * `requireAuth()` and drop off the record list and the manage literal. + * 256 -> 252. */ -const FROZEN_ENTRY_COUNT = 256; +const FROZEN_ENTRY_COUNT = 252; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2855,7 +2842,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(90); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index dd85c335d..82ba66592 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(90); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(87); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/life-events/[id]/route.ts b/src/app/api/life-events/[id]/route.ts index a9eee8948..6a7d6cd12 100644 --- a/src/app/api/life-events/[id]/route.ts +++ b/src/app/api/life-events/[id]/route.ts @@ -1,8 +1,8 @@ /** * `PATCH /api/life-events/{id}` edits one life event; `DELETE` soft-deletes - * it (v1.42, #613). Record-scoped like the list beside it, and MANAGE like - * the create: correcting or removing somebody's life event is a guardian's - * act. A foreign, unknown or already deleted id is the same 404. + * it (v1.42, #613). Owner-only like the list beside it (see there): no + * delegate at any level, and no guardian acting for a managed profile. A + * foreign, unknown or already deleted id is the same 404. * * The edit files the replaced dates, category and precision (C4) and names a * changed title or note without quoting either. The delete tombstones the @@ -10,7 +10,7 @@ */ import { NextRequest } from "next/server"; -import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -42,7 +42,7 @@ function notFound() { export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("manage", "profile"); + const { user } = await requireAuth(); const path = lifeEventIdPathSchema.safeParse(await params); if (!path.success) return notFound(); const existing = await findOwnLifeEvent(user.id, path.data.id); @@ -109,7 +109,7 @@ export const PATCH = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("manage", "profile"); + const { user } = await requireAuth(); const path = lifeEventIdPathSchema.safeParse(await params); if (!path.success) return notFound(); const existing = await findOwnLifeEvent(user.id, path.data.id); diff --git a/src/app/api/life-events/route.ts b/src/app/api/life-events/route.ts index 543a44d5f..cdb0d32a0 100644 --- a/src/app/api/life-events/route.ts +++ b/src/app/api/life-events/route.ts @@ -1,21 +1,25 @@ /** - * `GET /api/life-events` lists the record's life events, oldest first; + * `GET /api/life-events` lists the account's life events, oldest first; * `POST` adds one (v1.42, #613). Title and note are encrypted at rest and * never reach a model. Shapes and request schemas in `src/lib/day/`. * - * Part of the `profile` sharing domain, beside allergies, visits and the - * immunization history. The row store answers whether or not the `timeline` - * module is on, the data-layer posture of the other record tables, so a - * restore keeps working and re-enabling finds the events intact. + * Owner-only in v1.42. A life event (a loss, a separation, a new job) is + * the most personal row the record holds, and no share level was ever + * worded for it, so no delegate reaches it: not a `profile` share, not a + * legacy whole-record share, not MANAGE, not a guardian acting for a + * managed profile. Bare `requireAuth()`, which refuses under an + * acting-account switch and refuses a narrow Bearer scope. Sharing life + * events is a separate decision for a later release. * - * The create is a MANAGE verb: a life event is the person's own anchor, and - * recording one for somebody else is a guardian's act on a managed profile, - * not a helper's. Additive, rate-limited on the actor, audited with the - * category and the precision and never the title. + * The row store answers whether or not the `timeline` module is on, the + * data-layer posture of the other record tables, so a restore keeps working + * and re-enabling finds the events intact. The create is additive, + * rate-limited, audited with the category and the precision and never the + * title. */ import { NextRequest } from "next/server"; -import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -35,7 +39,7 @@ import { annotate } from "@/lib/logging/context"; import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read", "profile"); + const { user } = await requireAuth(); const events = await listLifeEvents(user.id); annotate({ action: { name: "life_event.list", entity_type: "life_event" }, @@ -47,9 +51,9 @@ export const GET = apiHandler(async () => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postLifeEvent)); async function postLifeEvent(request: NextRequest): Promise { - const { user, actor } = await requireRecordAuth("manage", "profile"); + const { user } = await requireAuth(); - const writeRl = await checkRecordWriteRateLimit(actor.id); + const writeRl = await checkRecordWriteRateLimit(user.id); if (!writeRl.allowed) { return apiError("Too many writes, try again later", 429, { errorCode: "record_write.rate_limited", diff --git a/src/lib/day/__tests__/sections.test.ts b/src/lib/day/__tests__/sections.test.ts index 6e3015738..f7dddcea7 100644 --- a/src/lib/day/__tests__/sections.test.ts +++ b/src/lib/day/__tests__/sections.test.ts @@ -67,14 +67,24 @@ describe("day access", () => { ); }); - it("keeps the environment rows for the owner even under a full grant", () => { + it("keeps the environment rows and the life events for the owner even under a full grant", () => { const access = resolveDayAccessFrom({ modules: ALL_ON, domainVisible: () => true, owner: false, }); expect(access.readable.has("environment")).toBe(false); - expect(access.notShared).toEqual(["environment"]); + expect(access.readable.has("lifeEvents")).toBe(false); + expect(access.notShared).toEqual(["environment", "lifeEvents"]); + }); + + it("opens the life events to the owner", () => { + const access = resolveDayAccessFrom({ + modules: ALL_ON, + domainVisible: () => true, + owner: true, + }); + expect(access.readable.has("lifeEvents")).toBe(true); }); it("maps every section to a real sharing domain or to the owner", () => { @@ -82,7 +92,8 @@ describe("day access", () => { const domain = DAY_SECTION_SHARE_DOMAIN[section]; if (domain !== null) expect(SHARE_DOMAINS).toContain(domain); } - expect(DAY_SECTION_SHARE_DOMAIN.lifeEvents).toBe("profile"); + // Owner-only in v1.42: no share level reaches them. + expect(DAY_SECTION_SHARE_DOMAIN.lifeEvents).toBeNull(); }); it("narrows readings type by type through their own module", () => { diff --git a/src/lib/day/sections.ts b/src/lib/day/sections.ts index c1ac90bfd..dafa083ec 100644 --- a/src/lib/day/sections.ts +++ b/src/lib/day/sections.ts @@ -14,8 +14,8 @@ * Every section maps to the sharing domain the routes that own its rows * already declare (the sharing guard freezes those), so the day can never show * a delegate more of a section than its own list would. The environment rows - * have no delegable route at all, so they map to no domain and are read for - * the owner only. + * and the life events have no delegable route at all, so they map to no + * domain and are read for the owner only. */ import type { MeasurementType } from "@/generated/prisma/enums"; @@ -32,8 +32,11 @@ import type { ShareDomain } from "@/lib/sharing/scope"; * * Mirrors the declarations of the routes that serve the same rows: readings, * sleep, workouts and check-up reminders are `measurements`; mood and the - * screeners are `mind`; allergies, visits, vaccinations, the anamnesis facts - * and life events are `profile`; symptoms ride `illness`. + * screeners are `mind`; allergies, visits, vaccinations and the anamnesis + * facts are `profile`; symptoms ride `illness`. Life events, like the + * environment rows, are owner-only in v1.42: their routes take no delegate + * at any level, so no share (a `profile` one, a legacy whole-record one, or + * MANAGE) shows them here either. */ export const DAY_SECTION_SHARE_DOMAIN: Readonly< Record @@ -55,7 +58,7 @@ export const DAY_SECTION_SHARE_DOMAIN: Readonly< cycle: "cycle", environment: null, lifestyle: "profile", - lifeEvents: "profile", + lifeEvents: null, }); /** The module that owns a section, from the one surface map. */ diff --git a/src/lib/openapi/routes/day.ts b/src/lib/openapi/routes/day.ts index 522af41eb..3fb3230af 100644 --- a/src/lib/openapi/routes/day.ts +++ b/src/lib/openapi/routes/day.ts @@ -32,15 +32,27 @@ import { idempotentWrite, recordRefusal, recordWriteRateLimitResponse, + SHARING_NOT_PERMITTED_DESCRIPTION, stdResponses, } from "./shared"; +const LIFE_EVENTS_OWNER_ONLY = + "Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, and not a guardian acting for a managed profile."; + +/** The 403 of an owner-only life-event route. */ +const lifeEventRefusal = { + "403": { + description: `${LIFE_EVENTS_OWNER_ONLY}\n\n${SHARING_NOT_PERMITTED_DESCRIPTION}`, + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + const TIMELINE_OFF = "`module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it off server-wide."; const lifeEventNotFound = { "404": { - description: "No such life event on this record.", + description: "No such life event on this account.", content: { "application/json": { schema: errorEnvelope } }, }, }; @@ -96,7 +108,7 @@ export const dayPaths: NonNullable = { tags: ["Records"], summary: "One day across the record", description: - "Everything on one local calendar day: what ran through it (medications, an illness, a cycle phase, a trip), the readings in its window with the person's usual range, what happened on it, and deterministic notable observations as keys with parameters. Sections of a switched-off module are left out; sections the caller's grant does not cover are named in `sections`. Read live from the record, never from the UTC rollups. A date that is not a real calendar date answers 422.", + "Everything on one local calendar day: what ran through it (medications, an illness, a cycle phase, a trip), the readings in its window with the person's usual range, what happened on it, and deterministic notable observations as keys with parameters. Sections of a switched-off module are left out; sections the caller's grant does not cover are named in `sections`. Life events and the environment are the owner's only: for a delegate at any level they are named `not_shared`. Read live from the record, never from the UTC rollups. A date that is not a real calendar date answers 422.", requestParams: { path: dayPathSchema }, responses: { "200": { @@ -117,7 +129,7 @@ export const dayPaths: NonNullable = { tags: ["Records"], summary: "The record over the years", description: - "Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of a switched-off module never are. Requires the opt-in `timeline` module.", + "Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is never sent to a delegate. Requires the opt-in `timeline` module.", requestParams: { query: timelineQuerySchema }, responses: { "200": { @@ -138,7 +150,7 @@ export const dayPaths: NonNullable = { tags: ["Records"], summary: "What the timeline can show", description: - "One row per lane: carries, thin or empty, a count, a detail as a message key with parameters, and the gaps with one in-app link each. The verdict is one of two words, never a score. Requires the opt-in `timeline` module.", + "One row per lane: carries, thin or empty, a count, a detail as a message key with parameters, and the gaps with one in-app link each. The verdict is one of two words, never a score. The `life` lane is reported to the owner only. Requires the opt-in `timeline` module.", responses: { "200": { description: "The readiness inventory.", @@ -161,7 +173,7 @@ export const dayPaths: NonNullable = { tags: ["Records"], summary: "List life events", description: - "The record's life events, oldest first; soft-deleted ones are excluded. Title and note are decrypted for the caller. Part of the `profile` sharing domain. Life events are never sent to a model.", + "The account's own life events, oldest first; soft-deleted ones are excluded. Title and note are decrypted for the caller. Owner-only: no delegate reads them at any share level. Life events are never sent to a model.", responses: { "200": { description: "The life events.", @@ -175,7 +187,7 @@ export const dayPaths: NonNullable = { }, }, ...stdResponses, - ...recordRefusal(), + ...lifeEventRefusal, }, }, post: { @@ -183,7 +195,7 @@ export const dayPaths: NonNullable = { tags: ["Records"], summary: "Add a life event", description: - "A dated anchor in the person's life. `precision` says how much of the date is known and applies to both ends: at MONTH a date is the first of its month, at YEAR the first of its year. `endDate`, when given, is not before `startDate`. Title (1-120 characters) and note (up to 2000) are encrypted at rest.", + "A dated anchor in the person's life. `precision` says how much of the date is known and applies to both ends: at MONTH a date is the first of its month, at YEAR the first of its year. `endDate`, when given, is not before `startDate`. Title (1-120 characters) and note (up to 2000) are encrypted at rest. Owner-only, like every life-event route.", requestBody: { required: true, content: { "application/json": { schema: lifeEventCreateSchema } }, @@ -198,7 +210,7 @@ export const dayPaths: NonNullable = { }, }, ...stdResponses, - ...recordRefusal(), + ...lifeEventRefusal, ...recordWriteRateLimitResponse, ...idempotentWrite(), }, @@ -226,7 +238,7 @@ export const dayPaths: NonNullable = { }, ...lifeEventNotFound, ...stdResponses, - ...recordRefusal(), + ...lifeEventRefusal, }, }, delete: { @@ -245,7 +257,7 @@ export const dayPaths: NonNullable = { }, ...lifeEventNotFound, ...stdResponses, - ...recordRefusal(), + ...lifeEventRefusal, }, }, }, diff --git a/src/lib/timeline/__tests__/lane-access.test.ts b/src/lib/timeline/__tests__/lane-access.test.ts new file mode 100644 index 000000000..e784da874 --- /dev/null +++ b/src/lib/timeline/__tests__/lane-access.test.ts @@ -0,0 +1,29 @@ +/** + * v1.42 — the `life` lane (life events, travel periods) is the owner's only. + * No share level reaches it: not `profile`, not a whole-record share. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { laneVisible, type TimelineAccess } from "../lanes"; + +const modules = { timeline: true } as unknown as TimelineAccess["modules"]; + +describe("the life lane", () => { + it("is hidden from a delegate whatever the grant covers", () => { + const delegate: TimelineAccess = { + modules, + domainVisible: () => true, + owner: false, + }; + expect(laneVisible("life", delegate)).toBe(false); + expect(laneVisible("allergies", delegate)).toBe(true); + }); + + it("is shown to the owner", () => { + expect( + laneVisible("life", { modules, domainVisible: () => true, owner: true }), + ).toBe(true); + }); +}); diff --git a/src/lib/timeline/lanes.ts b/src/lib/timeline/lanes.ts index 9b3760674..da037607a 100644 --- a/src/lib/timeline/lanes.ts +++ b/src/lib/timeline/lanes.ts @@ -12,9 +12,11 @@ * * Each lane belongs to one sharing domain and, where a module owns it, to * that module; a lane the caller may not see is not read. The `life` lane - * mixes life events (the timeline module's own content, `profile`) with - * travel periods (the environment module, owner only) and is filtered item - * by item. A travel item never carries a place: the label is sealed. + * holds life events (the timeline module's own content) and travel periods + * (the environment module); both are owner-only in v1.42, so the lane maps + * to no domain and no delegate sees it at any level. Travel periods are + * filtered item by item on the environment module. A travel item never + * carries a place: the label is sealed. */ import type { TimelineItem, TimelineLaneKey } from "@/lib/day/contract"; import { openText } from "@/lib/day/records"; @@ -26,11 +28,14 @@ import type { ShareDomain } from "@/lib/sharing/scope"; import { dateOnlyKey } from "@/lib/tz/date-only"; import { userDayKey } from "@/lib/tz/format"; -/** The sharing domain each lane reads. */ +/** + * The sharing domain each lane reads, or `null` for a lane only the record's + * owner sees. + */ export const TIMELINE_LANE_SHARE_DOMAIN: Readonly< - Record + Record > = Object.freeze({ - life: "profile", + life: null, illness: "illness", allergies: "profile", medications: "medications", @@ -54,7 +59,8 @@ export function laneVisible( ): boolean { const owner = surfaceModule(`timeline-lane:${lane}`); if (owner !== undefined && access.modules[owner] === false) return false; - return access.domainVisible(TIMELINE_LANE_SHARE_DOMAIN[lane]); + const domain = TIMELINE_LANE_SHARE_DOMAIN[lane]; + return domain === null ? access.owner : access.domainVisible(domain); } export interface StandingItem { @@ -90,10 +96,12 @@ function item( } async function lifeLane(frame: LaneFrame): Promise { + // `laneVisible` already keeps this lane from every delegate; the owner + // test is repeated here so a caller that skips it cannot read either half. const travelVisible = frame.access.owner && frame.access.modules.environment !== false; const [events, trips] = await Promise.all([ - frame.access.modules.timeline === false + !frame.access.owner || frame.access.modules.timeline === false ? Promise.resolve([]) : prisma.lifeEvent.findMany({ where: { userId: frame.userId, deletedAt: null }, diff --git a/src/lib/timeline/readiness.ts b/src/lib/timeline/readiness.ts index b0763f103..72e8a1e16 100644 --- a/src/lib/timeline/readiness.ts +++ b/src/lib/timeline/readiness.ts @@ -441,6 +441,7 @@ async function documentsLane(frame: Frame) { } async function lifeLane(frame: Frame) { + // Owner-only: `laneVisible` leaves this lane out for every delegate. const where = { userId: frame.userId, deletedAt: null }; const [count, first] = await Promise.all([ prisma.lifeEvent.count({ where }), diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 58eabf4ab..5d70af0c0 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -482,31 +482,6 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "delete", ), - // v1.42 (#613) — life events, MANAGE on all three verbs. - handler( - "app/api/life-events/route.ts", - "/api/life-events", - "POST", - "profile", - "manage", - "create", - ), - handler( - "app/api/life-events/[id]/route.ts", - "/api/life-events/[id]", - "PATCH", - "profile", - "manage", - "update", - ), - handler( - "app/api/life-events/[id]/route.ts", - "/api/life-events/[id]", - "DELETE", - "profile", - "manage", - "delete", - ), handler( "app/api/illness/episodes/[id]/day-logs/route.ts", "/api/illness/episodes/[id]/day-logs", diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts index cab2b746b..1633cd3cd 100644 --- a/tests/integration/day-view.test.ts +++ b/tests/integration/day-view.test.ts @@ -520,6 +520,170 @@ describe("life events", () => { }); }); +describe("life events are the owner's only", () => { + async function seedOwnerWithEvent() { + const db = getPrismaClient(); + const owner = await makeUser("le-owner", { timeline: true }); + await db.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date("2026-03-10T06:00:00.000Z"), + }, + }); + const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); + const event = await db.lifeEvent.create({ + data: { + userId: owner.id, + category: "LOSS", + startDate: "2026-03-10", + precision: "DAY", + titleEncrypted: encryptToBytes("Zqx private"), + }, + }); + return { owner, event }; + } + + async function manageWith(ownerId: string, delegateId: string) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: ownerId, + granteeId: delegateId, + access: "MANAGE", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegateId }); + const session = await signIn(delegateId); + await switchSessionTo(session.id, ownerId); + } + + const grants: Array< + [string, (ownerId: string, delegateId: string) => Promise] + > = [ + ["a profile share", (o, d) => shareWith(o, d, ["profile", "measurements"])], + ["a whole-record share", (o, d) => shareWith(o, d, null)], + ["a MANAGE share", manageWith], + ]; + + for (const [label, grant] of grants) { + it(`shows none to ${label}`, async () => { + const { owner, event } = await seedOwnerWithEvent(); + const delegate = await makeUser("le-delegate"); + await grant(owner.id, delegate.id); + + const routes = await import("@/app/api/life-events/route"); + const byId = await import("@/app/api/life-events/[id]/route"); + expect( + (await call(routes.GET as Handler, "GET", "/api/life-events")).status, + ).toBe(403); + expect( + ( + await call(routes.POST as Handler, "POST", "/api/life-events", { + category: "WORK", + startDate: "2024-01-01", + precision: "YEAR", + title: "New job", + }) + ).status, + ).toBe(403); + expect( + ( + await call( + byId.PATCH as Handler, + "PATCH", + `/api/life-events/${event.id}`, + { category: "FAMILY" }, + { id: event.id }, + ) + ).status, + ).toBe(403); + expect( + ( + await call( + byId.DELETE as Handler, + "DELETE", + `/api/life-events/${event.id}`, + undefined, + { id: event.id }, + ) + ).status, + ).toBe(403); + + const day = await json(await getDay("2026-03-10")); + expect(day.values.map((v) => v.value)).toEqual([80]); + expect(JSON.stringify(day)).not.toMatch(/Zqx|"lifeEvent"/); + expect(day.sections.lifeEvents).toEqual({ + available: false, + reason: "not_shared", + }); + + const { GET: index } = await import("@/app/api/day/index/route"); + const days = await json<{ days: Record }>( + await call( + index as Handler, + "GET", + "/api/day/index?from=2026-03-01&to=2026-03-31", + ), + ); + expect(days.days["2026-03-10"]).not.toContain("lifeEvents"); + + const { GET: timeline } = await import("@/app/api/timeline/route"); + const lanes = await call( + timeline as Handler, + "GET", + "/api/timeline?zoom=year&to=2026-03-31", + ); + if (lanes.status === 200) { + const body = + await json(lanes); + expect(body.lanes.map((l) => l.key)).not.toContain("life"); + expect(JSON.stringify(body)).not.toContain("Zqx"); + } else { + expect(lanes.status).toBe(403); + } + + const { GET: readiness } = + await import("@/app/api/timeline/readiness/route"); + const ready = await call( + readiness as Handler, + "GET", + "/api/timeline/readiness", + ); + if (ready.status === 200) { + const body = + await json( + ready, + ); + expect(body.lanes.map((l) => l.key)).not.toContain("life"); + } else { + expect(ready.status).toBe(403); + } + + // The event is untouched. + const row = await getPrismaClient().lifeEvent.findUniqueOrThrow({ + where: { id: event.id }, + }); + expect([row.category, row.deletedAt]).toEqual(["LOSS", null]); + }); + } + + it("shows the owner their own events in the timeline", async () => { + const { owner } = await seedOwnerWithEvent(); + await signIn(owner.id); + const { GET: timeline } = await import("@/app/api/timeline/route"); + const body = await json( + await call( + timeline as Handler, + "GET", + "/api/timeline?zoom=year&to=2026-03-31", + ), + ); + expect(body.lanes.map((l) => l.key)).toContain("life"); + }); +}); + describe("notable days", () => { it("marks a three-month high in the day, the index and the visit window", async () => { const db = getPrismaClient(); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index c76c669bc..e936adecd 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -1875,89 +1875,6 @@ manageContract("DELETE /api/vaccinations/custom/[id]", { )?.deletedAt !== null, }); -/* v1.42 (#613) — life events: MANAGE on all three verbs. */ - -async function makeLifeEvent(userId: string) { - const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); - return getPrismaClient().lifeEvent.create({ - data: { - userId, - category: "HOME", - startDate: "2023-09-01", - precision: "MONTH", - titleEncrypted: encryptToBytes("Moved"), - }, - }); -} - -manageContract("POST /api/life-events", { - contract: ADMITTED_MUTATING_HANDLERS.find( - (entry) => entry.route === "/api/life-events" && entry.action === "POST", - )!, - prepare: async (ownerId) => ({ ownerId }), - act: async () => { - const { POST } = await import("@/app/api/life-events/route"); - return call(POST as Handler, "POST", "/api/life-events", { - category: "WORK", - startDate: "2024-01-01", - precision: "YEAR", - title: "New job", - }); - }, - ok: 201, - auditAction: "life_event.create", - applied: async ({ ownerId }) => - (await getPrismaClient().lifeEvent.count({ - where: { userId: ownerId, category: "WORK" }, - })) === 1, -}); - -manageContract("PATCH /api/life-events/[id]", { - contract: ADMITTED_MUTATING_HANDLERS.find( - (entry) => - entry.route === "/api/life-events/[id]" && entry.action === "PATCH", - )!, - prepare: (ownerId) => makeLifeEvent(ownerId), - act: async (row) => { - const { PATCH } = await import("@/app/api/life-events/[id]/route"); - return call( - PATCH as Handler, - "PATCH", - `/api/life-events/${row.id}`, - { category: "FAMILY" }, - { id: row.id }, - ); - }, - ok: 200, - auditAction: "life_event.update", - applied: async (row) => - (await getPrismaClient().lifeEvent.findUnique({ where: { id: row.id } })) - ?.category === "FAMILY", -}); - -manageContract("DELETE /api/life-events/[id]", { - contract: ADMITTED_MUTATING_HANDLERS.find( - (entry) => - entry.route === "/api/life-events/[id]" && entry.action === "DELETE", - )!, - prepare: (ownerId) => makeLifeEvent(ownerId), - act: async (row) => { - const { DELETE } = await import("@/app/api/life-events/[id]/route"); - return call( - DELETE as Handler, - "DELETE", - `/api/life-events/${row.id}`, - undefined, - { id: row.id }, - ); - }, - ok: 200, - auditAction: "life_event.delete", - applied: async (row) => - (await getPrismaClient().lifeEvent.findUnique({ where: { id: row.id } })) - ?.deletedAt !== null, -}); - proveWriteRoute("POST /api/vaccinations/[id]/booster", { route: "/api/vaccinations/[id]/booster", // The dose the booster is planned against. `makeVaccination` records a