fix(sharing): keep life events to their owner

Life events rode the profile sharing domain, so a profile share, a
legacy whole-record share and a MANAGE grant all read them, and MANAGE
could write them. No share level was ever worded for them, so in this
release they are the owner's only.

The life-event routes take a bare requireAuth, which refuses under an
acting-account switch. The day section and the timeline's life lane map
to no domain, so a delegate sees the section named not_shared and never
gets the lane, its readiness row or the day index marks. The profile
consent copy no longer names life events, the sharing guard and the
mutation inventory drop the four entries, and the OpenAPI descriptions
say owner-only.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:37:57 +02:00
1 parent e2ed62c74f
commit f950dffad4
21 files changed
+323 -196

No files matched your search

+31 -15
View File
@@ -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:
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -11881,7 +11881,7 @@
},
"profile": {
"label": "건강 배경",
"detail": "알레르기, 가족력, 건강 프로필의 항목들, 진료와 병원, 예방접종 기록, 삶의 사건이에요."
"detail": "알레르기, 가족력, 건강 프로필의 항목들, 진료와 병원, 예방접종 기록이에요."
},
"illness": {
"label": "아픈 날",
+1 -1
View File
@@ -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",
+7 -20
View File
@@ -1045,14 +1045,6 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
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<string, ManageEntry> = {
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<string, string> = {
*
* 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 }) =>
@@ -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(
+6 -6
View File
@@ -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);
+17 -13
View File
@@ -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<Response> {
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",
+14 -3
View File
@@ -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", () => {
+8 -5
View File
@@ -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<DaySectionKey, ShareDomain | null>
@@ -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. */
+22 -10
View File
@@ -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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["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.",
"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<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...recordRefusal(),
...lifeEventRefusal,
},
},
post: {
@@ -183,7 +195,7 @@ export const dayPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...recordRefusal(),
...lifeEventRefusal,
...recordWriteRateLimitResponse,
...idempotentWrite(),
},
@@ -226,7 +238,7 @@ export const dayPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...lifeEventNotFound,
...stdResponses,
...recordRefusal(),
...lifeEventRefusal,
},
},
delete: {
@@ -245,7 +257,7 @@ export const dayPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...lifeEventNotFound,
...stdResponses,
...recordRefusal(),
...lifeEventRefusal,
},
},
},
@@ -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);
});
});
+16 -8
View File
@@ -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<TimelineLaneKey, ShareDomain>
Record<TimelineLaneKey, ShareDomain | null>
> = 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<LaneRead> {
// `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 },
+1
View File
@@ -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 }),
-25
View File
@@ -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",
+164
View File
@@ -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<void>]
> = [
["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<Day>(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<string, string[]> }>(
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<import("@/lib/day/contract").TimelineResponse>(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<import("@/lib/day/contract").TimelineReadinessResponse>(
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<import("@/lib/day/contract").TimelineResponse>(
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();
@@ -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