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

+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 }),