From 3b3ef5c0a14451d1471b4740e10dd6ac9abb8bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 6 Nov 2026 18:03:10 +0100 Subject: [PATCH] fix(cycle): keep the calendar feed to the owner and a managed profile's guardian A calendar feed is a standing credential whose contents end up in a third party's calendar cloud. Putting a record's cycle dates there is the decision of the person they are about, or of the guardian of somebody who cannot make it, and no delegate of an adult's record, MANAGE included. The subscription routes resolve through requireGuardianAuth instead of requireRecordAuth("manage", "cycle"): the owner on their own record, or a MANAGE grant on a record carrying the managed-profile marker. Every verb follows, the revoke too. The feed re-reads the marker on each fetch as well as the guardian's grant, so a row minted for somebody else on an adult's record is a 404 whatever grant stands behind it. The settings card is offered only where the routes answer. The routes move from the delegable and MANAGE literals to the guardian list, and the MANAGE handler inventory is back to 93. --- docs/api/openapi.yaml | 16 ++-- docs/using/cycle-calendar-feed.md | 8 +- .../0494_cycle_calendar_feed/migration.sql | 2 +- prisma/schema.prisma | 9 ++- .../recent-proof-surface-guard.test.ts | 4 +- src/__tests__/sharing-surface-guard.test.ts | 33 +++----- src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- .../cycle/feed-subscription/rotate/route.ts | 8 +- src/app/api/cycle/feed-subscription/route.ts | 25 ++++--- .../__tests__/cycle-settings-tab.test.tsx | 53 +++++++++++++ src/components/cycle/cycle-settings-tab.tsx | 12 ++- src/lib/cycle/feed/__tests__/serve.test.ts | 29 ++++++- src/lib/cycle/feed/manage.ts | 6 +- src/lib/cycle/feed/serve.ts | 17 ++++- src/lib/openapi/routes/cycle-feed.ts | 4 +- tests/fixtures/v137/sharing-matrix.ts | 32 -------- tests/integration/cycle-calendar-feed.test.ts | 63 +++++++++++++++- .../sharing-manage-handler-matrix.test.ts | 75 +------------------ 18 files changed, 227 insertions(+), 171 deletions(-) create mode 100644 src/components/cycle/__tests__/cycle-settings-tab.test.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a6d1ab4e2..fe43de718 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -790,11 +790,12 @@ paths: description: "Anonymous: a calendar provider polls this URL, so the raw `hlf_` token in the path (optionally followed by `.ics`) is the ONLY credential. Rate-limited per source address before any lookup and per token after it. Every gate is re-read on each fetch: the record and the minting account are not pending deletion, the operator's - external-access switch is on, cycle tracking is available, and a feed minted by a delegate still has a live - MANAGE grant over the cycle section. Every miss (malformed, unknown, revoked, switched off, deleted, lapsed - grant) is the same 404. The body is RFC 5545: all-day events, dates only, discreet titles by default, the next - few periods with widening bands (self-capped), optionally past periods and, for TRYING_TO_CONCEIVE, the next - fertile window. `ETag` is the content hash; a matching `If-None-Match` answers 304." + external-access switch is on, cycle tracking is available, and a feed minted by a managed profile's guardian + still sits on a managed profile the guardian holds a live MANAGE grant over. Every miss (malformed, unknown, + revoked, switched off, deleted, lapsed grant) is the same 404. The body is RFC 5545: all-day events, dates only, + discreet titles by default, the next few periods with widening bands (self-capped), optionally past periods and, + for TRYING_TO_CONCEIVE, the next fertile window. `ETag` is the content hash; a matching `If-None-Match` answers + 304." security: [] parameters: - in: path @@ -847,8 +848,9 @@ paths: generic auth gate answers it before the route's own checks run. Otherwise: Calendar subscriptions are managed over a browser session only: any Bearer token gets `cycle.feed.cookie_only`. Cycle tracking switched off for the record answers `cycle.disabled`; the operator's external-access switch answers - `cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner and by a MANAGE - delegate whose grant covers the cycle section, never by a READ or WRITE delegate. + `cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner on their own + record and by the guardian of a managed profile; a delegate on an adult's record is refused at every grant + level, MANAGE included. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). diff --git a/docs/using/cycle-calendar-feed.md b/docs/using/cycle-calendar-feed.md index e8a8335d7..b05c53d66 100644 --- a/docs/using/cycle-calendar-feed.md +++ b/docs/using/cycle-calendar-feed.md @@ -13,9 +13,11 @@ dates on its servers, and turning the subscription off later does not remove copies it already holds. HealthLog asks you to confirm it is you (a password or passkey) and then shows the link once. Copy it, or use **Open in calendar app**. -Only the record's owner, or someone who manages it with a MANAGE grant that -covers cycle data, can turn it on. It is managed on the web; the iOS app shows -where to find it. +Only you can turn it on for your own record. For a managed profile (a child's +record, for instance), its guardian can. Someone you have shared your record +with cannot, whatever level of access you gave them: the feed puts your dates +in their calendar provider's cloud, and that is your decision alone. It is +managed on the web; the iOS app shows where to find it. ## Adding it to your calendar diff --git a/prisma/migrations/0494_cycle_calendar_feed/migration.sql b/prisma/migrations/0494_cycle_calendar_feed/migration.sql index f967394ee..64d0c4c84 100644 --- a/prisma/migrations/0494_cycle_calendar_feed/migration.sql +++ b/prisma/migrations/0494_cycle_calendar_feed/migration.sql @@ -9,7 +9,7 @@ -- here only. -- -- Both foreign keys cascade: deleting the record ends its feed, and deleting --- the carrier who minted it does too, so a delegate's account going away +-- the carrier who minted it does too, so a guardian's account going away -- cannot leave a link behind that nobody can revoke. -- -- One DO block, so the file is one statement that rolls back whole and the diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e26d5a222..8b102b41c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1168,8 +1168,8 @@ model User { cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms") // #1350 — the subscribable cycle forecast. `cycleCalendarFeeds` is the // record the feed serves; `cycleCalendarFeedsMinted` is the carrier who - // minted it, which is the owner on their own record and a MANAGE delegate - // on a record they manage. + // minted it, which is the owner on their own record and the guardian on a + // managed profile. cycleCalendarFeeds CycleCalendarFeed[] @relation("CycleCalendarFeedSubject") cycleCalendarFeedsMinted CycleCalendarFeed[] @relation("CycleCalendarFeedCreator") @@ -8360,8 +8360,9 @@ model CycleCalendarFeed { id String @id @default(cuid()) /// The record whose forecast the feed serves. userId String @map("user_id") - /// Who minted it: the owner, or a MANAGE delegate. A carrier's feed is - /// re-checked against their live grant on every fetch. + /// Who minted it: the owner, or the guardian of a managed profile. A + /// guardian's feed is re-checked against their live MANAGE grant and the + /// managed-profile marker on every fetch. createdById String @map("created_by_id") /// HMAC-SHA256 of the raw `hlf_` token. diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index 459d7a0c4..7bf53e7e2 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -98,7 +98,7 @@ const TOKEN_ARM_COOKIE_PROOF_ROUTES = [ ].sort(); /** - * Routes that resolve through `requireRecordAuth` and are cookie-only: they + * Routes that resolve through `requireGuardianAuth` and are cookie-only: they * refuse a Bearer caller outright, then take the cookie arm. The cycle * calendar feed's mint and rotate (#1350) hand out a standing URL credential, * so no token may reach them and the session needs a fresh proof. @@ -163,7 +163,7 @@ describe("the recent-proof gate", () => { it("a cookie-only record route refuses a token before it asks", () => { for (const rel of RECORD_COOKIE_ONLY_PROOF_ROUTES) { const src = read(rel); - expect(importedAs(rel, "requireRecordAuth"), rel).toHaveLength(1); + expect(importedAs(rel, "requireGuardianAuth"), rel).toHaveLength(1); // Every proof call sits after a Bearer refusal in the same handler. const proofs = [...src.matchAll(/await assertRecentCookieProof\(/g)]; expect(proofs.length, rel).toBeGreaterThan(0); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 8e2f9537b..f899a5e6b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1403,14 +1403,6 @@ const DELEGABLE_ROUTES: Record = { domain: "cycle", why: "Setting a period boundary, which re-anchors the neighbouring cycles. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", }, - "app/api/cycle/feed-subscription/route.ts": { - domain: "cycle", - why: "The record's calendar subscription (#1350): its status, turning it on, its options and turning it off. Reached only through this module's MANAGE arm, and only over a cookie; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", - }, - "app/api/cycle/feed-subscription/rotate/route.ts": { - domain: "cycle", - why: "A new link for the record's calendar subscription, the old one revoked in the same transaction (#1350). Reached only through this module's MANAGE arm, and only over a cookie; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", - }, "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": { domain: "medications", why: "Editing and deleting an era; the delete is hard, so C3 carries its dates and dose. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", @@ -1887,16 +1879,6 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C4"], why: "Setting a period boundary, which re-anchors the neighbouring cycles. C4 on the dates it moves.", }, - "app/api/cycle/feed-subscription/route.ts": { - domain: "cycle", - conditions: ["C1", "C3", "C4"], - why: "The record's calendar subscription (#1350), the one credential a MANAGE delegate may mint. It extends no reach: the feed serves dates the grant already lets the manager read, and every fetch re-reads the manager's live MANAGE grant over the cycle section, so revoking or narrowing the grant ends the link on the provider's next poll, and a handover claim revokes it outright. Cookie-only with a fresh proof on the mint, so a Bearer token cannot leave one behind. C1 on the mint bucket, C3 on the revoke (the credential is unusable after it), C4 on the options.", - }, - "app/api/cycle/feed-subscription/rotate/route.ts": { - domain: "cycle", - conditions: ["C1"], - why: "Replacing the subscription's link in one transaction (#1350). Same argument as the subscription route: no reach beyond the grant, re-checked per fetch, cookie-only with a fresh proof. C1 on the shared mint bucket.", - }, "app/api/cycle/symptoms/custom/route.ts": { domain: "cycle", conditions: ["C1"], @@ -2172,6 +2154,10 @@ const GUARDIAN_ROUTES: Record = { "Starts an OAuth connect for the managed record a guardian administers. It returns the consent URL and the state names the managed record; the callback re-checks the browser's live MANAGE grant before any token lands. Refused on an adult record by the marker, at every grant level.", "app/api/record-settings/integrations/[provider]/disconnect/route.ts": "Disconnects a provider from the managed record a guardian administers, through the same disconnect the owner runs. Imported data stays. Refused on an adult record by the marker, at every grant level.", + "app/api/cycle/feed-subscription/route.ts": + "The record's cycle calendar subscription (#1350): status, turning it on, its options, turning it off. A standing credential whose contents land in a third party's calendar cloud, so only the person the dates are about or the guardian of a managed profile may hold one; refused on an adult record at every grant level, MANAGE included. Cookie-only, fresh proof on the mint; the feed re-reads the grant and the marker on every fetch and a handover claim revokes it.", + "app/api/cycle/feed-subscription/rotate/route.ts": + "A new link for the record's cycle calendar subscription, the old one revoked in the same transaction (#1350). Same argument as the subscription route: owner or managed-profile guardian only, cookie-only, fresh proof.", }; /** @@ -2314,11 +2300,12 @@ const ACTOR_ROUTES: Record = { * manage literal: it was refused under a switch while the medication's PUT, * which used to carry the end, admitted a manager. 272 -> 274. * - * The cycle calendar subscription (#1350) and its rotate join the record list - * and the manage literal: the owner and a MANAGE delegate over the cycle - * section may turn it on, change it, rotate it and turn it off. 276 -> 280. + * The cycle calendar subscription (#1350) and its rotate join the guardian + * list: a standing credential to a third-party calendar is the owner's to + * make, or a managed profile's guardian's, never an adult record's delegate's. + * 276 -> 278. */ -const FROZEN_ENTRY_COUNT = 280; +const FROZEN_ENTRY_COUNT = 278; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -3096,7 +3083,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(97); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(93); 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 730299d84..1de074106 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(97); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(93); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts index 3e58f83bb..4f6705ae1 100644 --- a/src/app/api/cycle/feed-subscription/rotate/route.ts +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -8,15 +8,15 @@ * URL is returned exactly once. * * Same gates as turning the subscription on: cookie session only, the owner or - * a MANAGE delegate over the cycle section, a fresh proof, the operator's - * external-access switch, and the per-actor mint bucket. + * the guardian of a managed profile (`requireGuardianAuth`), a fresh proof, + * the operator's external-access switch, and the per-actor mint bucket. */ import { NextRequest } from "next/server"; import { apiHandler, assertRecentCookieProof, - requireRecordAuth, + requireGuardianAuth, } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; import { isApiGloballyEnabled } from "@/lib/app-settings"; @@ -37,7 +37,7 @@ import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; export const POST = apiHandler(async (request: NextRequest) => { - const auth = await requireRecordAuth("manage", "cycle"); + const auth = await requireGuardianAuth(); if (auth.authMethod !== "cookie") return cookieOnlyRefusal(); const { user, actor } = auth; await assertRecentCookieProof(actor, auth.session.id); diff --git a/src/app/api/cycle/feed-subscription/route.ts b/src/app/api/cycle/feed-subscription/route.ts index 0b425bcf2..8b8b3aba9 100644 --- a/src/app/api/cycle/feed-subscription/route.ts +++ b/src/app/api/cycle/feed-subscription/route.ts @@ -12,18 +12,23 @@ * A separate namespace from the feed itself (`/api/cycle/feed/`), so * neither the path redactor nor the public-path prefix can ever catch it. * - * Auth: cookie session only, through `requireRecordAuth("manage", "cycle")`: - * the owner, or a MANAGE delegate whose grant covers the cycle section. A - * READ or WRITE delegate is refused. Turning it on also needs a fresh proof, - * because the link outlives the session that made it. A delegate's feed is - * re-checked against their grant on every fetch, and a handover claim ends it. + * Auth: cookie session only, through `requireGuardianAuth()`: the record's + * owner on their own record, or the guardian of a managed profile (a MANAGE + * grant on a record that carries the managed-profile marker). A delegate on + * an adult's record is refused at every grant level, MANAGE included: the + * feed is a standing credential whose contents end up in a third party's + * calendar cloud, and only the person the data is about, or the guardian of + * somebody who cannot decide for themselves, may put them there. Turning it + * on and rotating also need a fresh proof. A guardian's feed is re-checked + * against the live grant and the marker on every fetch, and a handover claim + * ends it. */ import { NextRequest } from "next/server"; import { apiHandler, assertRecentCookieProof, - requireRecordAuth, + requireGuardianAuth, } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; import { parseJsonBody } from "@/lib/api/body"; @@ -57,7 +62,7 @@ import { import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed"; export const GET = apiHandler(async () => { - const auth = await requireRecordAuth("manage", "cycle"); + const auth = await requireGuardianAuth(); if (auth.authMethod !== "cookie") return cookieOnlyRefusal(); const { user } = auth; annotate({ action: { name: "cycle.feed.status" } }); @@ -69,7 +74,7 @@ export const GET = apiHandler(async () => { }); export const POST = apiHandler(async (request: NextRequest) => { - const auth = await requireRecordAuth("manage", "cycle"); + const auth = await requireGuardianAuth(); if (auth.authMethod !== "cookie") return cookieOnlyRefusal(); const { user, actor } = auth; // The proof is the person at the keyboard, so it is the actor's session. @@ -131,7 +136,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); export const PATCH = apiHandler(async (request: NextRequest) => { - const auth = await requireRecordAuth("manage", "cycle"); + const auth = await requireGuardianAuth(); if (auth.authMethod !== "cookie") return cookieOnlyRefusal(); const { user } = auth; annotate({ action: { name: "cycle.feed.update" } }); @@ -207,7 +212,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { }); export const DELETE = apiHandler(async () => { - const auth = await requireRecordAuth("manage", "cycle"); + const auth = await requireGuardianAuth(); if (auth.authMethod !== "cookie") return cookieOnlyRefusal(); const { user } = auth; annotate({ action: { name: "cycle.feed.revoke" } }); diff --git a/src/components/cycle/__tests__/cycle-settings-tab.test.tsx b/src/components/cycle/__tests__/cycle-settings-tab.test.tsx new file mode 100644 index 000000000..63faa6b8d --- /dev/null +++ b/src/components/cycle/__tests__/cycle-settings-tab.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const caps = vi.hoisted(() => ({ + value: { inSharedRecord: false, recordKind: "self", level: null } as { + inSharedRecord: boolean; + recordKind: string; + level: string | null; + }, +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => caps.value, +})); +vi.mock("../cycle-settings", () => ({ + CycleSettings: () =>
, +})); +vi.mock("../cycle-calendar-feed-card", () => ({ + CycleCalendarFeedCard: () =>
, +})); + +import { CycleSettingsTab } from "../cycle-settings-tab"; +import type { CycleProfileDTO } from "../types"; + +const PROFILE = { updatedAt: "2026-11-01T00:00:00.000Z" } as CycleProfileDTO; + +function render(value: typeof caps.value) { + caps.value = value; + return renderToStaticMarkup(); +} + +describe(" calendar subscription (#1350)", () => { + it("offers it on one's own record", () => { + expect( + render({ inSharedRecord: false, recordKind: "self", level: null }), + ).toContain("cycle-calendar-feed"); + }); + + it("offers it to the guardian of a managed profile", () => { + expect( + render({ inSharedRecord: true, recordKind: "managed", level: "manage" }), + ).toContain("cycle-calendar-feed"); + }); + + it("does not offer it to a MANAGE delegate on an adult's record", () => { + const html = render({ + inSharedRecord: true, + recordKind: "shared", + level: "manage", + }); + expect(html).toContain("cycle-settings-form"); + expect(html).not.toContain("cycle-calendar-feed"); + }); +}); diff --git a/src/components/cycle/cycle-settings-tab.tsx b/src/components/cycle/cycle-settings-tab.tsx index 9cb8c2227..1d388ce23 100644 --- a/src/components/cycle/cycle-settings-tab.tsx +++ b/src/components/cycle/cycle-settings-tab.tsx @@ -1,5 +1,7 @@ "use client"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; + import { CycleCalendarFeedCard } from "./cycle-calendar-feed-card"; import { CycleSettings } from "./cycle-settings"; import type { CycleProfileDTO } from "./types"; @@ -8,12 +10,20 @@ import type { CycleProfileDTO } from "./types"; * The cycle page's settings tab: the profile form, then the calendar * subscription (#1350). The form remounts on a profile refetch (`key`), the * subscription card keeps its own state, including a link it just showed. + * + * The card is the owner's on their own record, and a guardian's on a managed + * profile they manage: the routes behind it (`requireGuardianAuth`) refuse a + * delegate on an adult's record at every level, so the card is not offered + * there rather than opening onto a refusal. */ export function CycleSettingsTab({ profile }: { profile: CycleProfileDTO }) { + const { inSharedRecord, recordKind, level } = useRecordCapabilities(); + const mayHoldFeed = + !inSharedRecord || (recordKind === "managed" && level === "manage"); return (
- + {mayHoldFeed ? : null}
); } diff --git a/src/lib/cycle/feed/__tests__/serve.test.ts b/src/lib/cycle/feed/__tests__/serve.test.ts index ae6da70c7..4aa12ccc8 100644 --- a/src/lib/cycle/feed/__tests__/serve.test.ts +++ b/src/lib/cycle/feed/__tests__/serve.test.ts @@ -83,6 +83,7 @@ beforeEach(() => { id: "subject", timezone: "Europe/Berlin", locale: "en", + managedProfileAt: null, }); gates.api.mockResolvedValue(true); gates.cycle.mockResolvedValue(true); @@ -187,12 +188,18 @@ describe("serveCycleFeed", () => { expect(built.build).not.toHaveBeenCalled(); }); - describe("a feed a delegate minted", () => { + describe("a feed a guardian minted on a managed profile", () => { beforeEach(() => { db.cycleCalendarFeed.findFirst.mockResolvedValue({ ...FEED_ROW, createdById: "manager", }); + db.user.findFirst.mockResolvedValue({ + id: "subject", + timezone: "Europe/Berlin", + locale: "en", + managedProfileAt: new Date("2026-01-01"), + }); }); const grant = (overrides: Record = {}) => ({ @@ -227,13 +234,31 @@ describe("serveCycleFeed", () => { await expectBlunt404(await fetchFeed()); }); + it("is a blunt 404 once the profile is an adult's account", async () => { + // A live MANAGE grant is not enough: only a guardian of a managed + // profile may hold a feed on somebody else's record. + gates.grant.mockResolvedValue(grant()); + db.user.findFirst.mockResolvedValue({ + id: "subject", + timezone: null, + locale: null, + managedProfileAt: null, + }); + await expectBlunt404(await fetchFeed()); + }); + it("is a blunt 404 once the manager's account is gone", async () => { gates.grant.mockResolvedValue(grant()); db.user.findFirst.mockImplementation( async (args: { where: { id: string } }) => args.where.id === "manager" ? null - : { id: "subject", timezone: null, locale: null }, + : { + id: "subject", + timezone: null, + locale: null, + managedProfileAt: new Date("2026-01-01"), + }, ); await expectBlunt404(await fetchFeed()); }); diff --git a/src/lib/cycle/feed/manage.ts b/src/lib/cycle/feed/manage.ts index 76aafab67..e85892f28 100644 --- a/src/lib/cycle/feed/manage.ts +++ b/src/lib/cycle/feed/manage.ts @@ -4,9 +4,9 @@ * Management is cookie-only. Minting hands out a standing URL credential, so * it needs the person at the keyboard (a fresh proof on the session) and is * not reachable from a Bearer token at all; the native app shows "set up on - * the web" instead. The routes resolve through `requireRecordAuth("manage", - * "cycle")`, so the owner and a MANAGE delegate over the cycle section reach - * them and a READ or WRITE delegate does not. + * the web" instead. The routes resolve through `requireGuardianAuth()`, so + * the owner and the guardian of a managed profile reach them, and every + * delegate on an adult's record (MANAGE included) does not. */ import { apiError } from "@/lib/api-response"; diff --git a/src/lib/cycle/feed/serve.ts b/src/lib/cycle/feed/serve.ts index 3e9bed107..73295d05a 100644 --- a/src/lib/cycle/feed/serve.ts +++ b/src/lib/cycle/feed/serve.ts @@ -17,8 +17,11 @@ * 5. The gates, all read live and none of them through a session: the * record and the carrier are not pending deletion, the operator's * external-access switch is on, cycle tracking is available for the - * record (user toggle AND operator module switch), and a feed minted by - * a delegate still has a live MANAGE grant that covers the cycle section. + * record (user toggle AND operator module switch), and a feed a + * guardian minted still sits on a managed profile the guardian holds a + * live MANAGE grant over. Only a guardian of a managed profile can mint + * for somebody else (`requireGuardianAuth`); the marker is re-read here + * so a feed can never outlive the profile becoming an adult's account. * * Every miss is the same 404 with the same bytes: unknown, malformed, * revoked, switched off, deleted, or a grant that has lapsed. A prober learns @@ -123,7 +126,12 @@ async function feedStillAllowed(feed: { const [subject, apiOn, cycleOn] = await Promise.all([ prisma.user.findFirst({ where: activeAccount({ id: feed.userId }), - select: { id: true, timezone: true, locale: true }, + select: { + id: true, + timezone: true, + locale: true, + managedProfileAt: true, + }, }), isApiGloballyEnabled(), isCycleAvailableForUser(feed.userId), @@ -133,6 +141,9 @@ async function feedStillAllowed(feed: { if (!cycleOn) return { ok: false, reason: "cycle_disabled" }; if (feed.createdById !== feed.userId) { + if (subject.managedProfileAt === null) { + return { ok: false, reason: "not_managed" }; + } const [carrier, grant] = await Promise.all([ prisma.user.findFirst({ where: activeAccount({ id: feed.createdById }), diff --git a/src/lib/openapi/routes/cycle-feed.ts b/src/lib/openapi/routes/cycle-feed.ts index 851ddd022..a8ee0a7b3 100644 --- a/src/lib/openapi/routes/cycle-feed.ts +++ b/src/lib/openapi/routes/cycle-feed.ts @@ -114,7 +114,7 @@ const feedOptionsRequest = cycleFeedOptionsPatchSchema.meta({ }); const MANAGEMENT_REFUSALS = recordRefusal( - "Calendar subscriptions are managed over a browser session only: any Bearer token gets `cycle.feed.cookie_only`. Cycle tracking switched off for the record answers `cycle.disabled`; the operator's external-access switch answers `cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner and by a MANAGE delegate whose grant covers the cycle section, never by a READ or WRITE delegate.", + "Calendar subscriptions are managed over a browser session only: any Bearer token gets `cycle.feed.cookie_only`. Cycle tracking switched off for the record answers `cycle.disabled`; the operator's external-access switch answers `cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner on their own record and by the guardian of a managed profile; a delegate on an adult's record is refused at every grant level, MANAGE included.", ); const REPROOF_401 = { @@ -145,7 +145,7 @@ export const cycleFeedPaths: NonNullable = { tags: ["Cycle"], summary: "The subscribable cycle forecast as iCalendar (public, #1350)", description: - "Anonymous: a calendar provider polls this URL, so the raw `hlf_` token in the path (optionally followed by `.ics`) is the ONLY credential. Rate-limited per source address before any lookup and per token after it. Every gate is re-read on each fetch: the record and the minting account are not pending deletion, the operator's external-access switch is on, cycle tracking is available, and a feed minted by a delegate still has a live MANAGE grant over the cycle section. Every miss (malformed, unknown, revoked, switched off, deleted, lapsed grant) is the same 404. The body is RFC 5545: all-day events, dates only, discreet titles by default, the next few periods with widening bands (self-capped), optionally past periods and, for TRYING_TO_CONCEIVE, the next fertile window. `ETag` is the content hash; a matching `If-None-Match` answers 304.", + "Anonymous: a calendar provider polls this URL, so the raw `hlf_` token in the path (optionally followed by `.ics`) is the ONLY credential. Rate-limited per source address before any lookup and per token after it. Every gate is re-read on each fetch: the record and the minting account are not pending deletion, the operator's external-access switch is on, cycle tracking is available, and a feed minted by a managed profile's guardian still sits on a managed profile the guardian holds a live MANAGE grant over. Every miss (malformed, unknown, revoked, switched off, deleted, lapsed grant) is the same 404. The body is RFC 5545: all-day events, dates only, discreet titles by default, the next few periods with widening bands (self-capped), optionally past periods and, for TRYING_TO_CONCEIVE, the next fertile window. `ETag` is the content hash; a matching `If-None-Match` answers 304.", security: [], requestParams: { path: z.object({ diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 1ca69ae54..00df119aa 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -754,38 +754,6 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "create", ), - handler( - "app/api/cycle/feed-subscription/route.ts", - "/api/cycle/feed-subscription", - "POST", - "cycle", - "manage", - "create", - ), - handler( - "app/api/cycle/feed-subscription/route.ts", - "/api/cycle/feed-subscription", - "PATCH", - "cycle", - "manage", - "update", - ), - handler( - "app/api/cycle/feed-subscription/route.ts", - "/api/cycle/feed-subscription", - "DELETE", - "cycle", - "manage", - "delete", - ), - handler( - "app/api/cycle/feed-subscription/rotate/route.ts", - "/api/cycle/feed-subscription/rotate", - "POST", - "cycle", - "manage", - "create", - ), handler( "app/api/medications/categories/route.ts", "/api/medications/categories", diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts index 1f990eb71..391ba30c5 100644 --- a/tests/integration/cycle-calendar-feed.test.ts +++ b/tests/integration/cycle-calendar-feed.test.ts @@ -289,6 +289,67 @@ describe("turning the subscription on", () => { expect(await db().cycleCalendarFeed.count()).toBe(0); }); + it("refuses a MANAGE delegate on an adult's record, for every verb", async () => { + const owner = await makeUser("owner"); + const manager = await makeUser("manager"); + // The owner's own feed exists, so a refused DELETE has something to miss. + await mint(owner.id); + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: manager.id, + access: "MANAGE", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: manager.id }); + const session = await signIn(manager.id); + await switchSessionTo(session.id, owner.id); + + for (const res of [ + await manage("GET"), + await manage("POST"), + await manage("PATCH", { horizonCycles: 6 }), + await manage("DELETE"), + await rotate(), + ]) { + expect(res.status).toBe(403); + } + const feeds = await db().cycleCalendarFeed.findMany({ + where: { userId: owner.id }, + }); + expect(feeds).toHaveLength(1); + expect(feeds[0].revokedAt).toBeNull(); + expect(feeds[0].horizonCycles).toBe(3); + expect(feeds[0].createdById).toBe(owner.id); + }); + + it("serves no feed a delegate holds on an adult's record, even with MANAGE", async () => { + // Written directly, as a row from before the narrowing would look: the + // fetch path refuses it on the missing managed-profile marker alone. + const owner = await makeUser("owner"); + const manager = await makeUser("manager"); + await seedCycles(owner.id); + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: manager.id, + access: "MANAGE", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: manager.id }); + const { mintFeedToken } = await import("@/lib/cycle/feed/token"); + const minted = mintFeedToken(); + await db().cycleCalendarFeed.create({ + data: { + userId: owner.id, + createdById: manager.id, + tokenHash: minted.hash, + tokenPrefix: minted.prefix, + }, + }); + await expect404(await fetchFeed(`${minted.raw}.ics`)); + }); + it("refuses the fertile window outside TRYING_TO_CONCEIVE", async () => { const owner = await makeUser("owner"); await mint(owner.id); @@ -432,7 +493,7 @@ describe("fetching the feed", () => { expect(throttled).toBe(true); }); - it("dies with the carrier's MANAGE grant, and a handover claim revokes it", async () => { + it("lets a managed profile's guardian hold one, until the grant or the handover ends it", async () => { const profile = await makeUser("profile", { managedProfileAt: new Date() }); const guardian = await makeUser("guardian"); await seedCycles(profile.id); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 01c3a88f9..12f5c2159 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -280,7 +280,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(97); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(93); }); }); @@ -2678,75 +2678,6 @@ proveManageRoute("POST /api/cycle/symptoms/custom", { })) === 1, }); -/** A live calendar feed on the record, minted as the owner would (#1350). */ -async function makeCycleFeed(ownerId: string, prefix = "hlf_matr") { - await enableCycle(ownerId); - return getPrismaClient().cycleCalendarFeed.create({ - data: { - userId: ownerId, - createdById: ownerId, - tokenHash: `matrix-${ownerId}-${prefix}`, - tokenPrefix: prefix, - }, - }); -} - -async function liveCycleFeeds(ownerId: string) { - return getPrismaClient().cycleCalendarFeed.findMany({ - where: { userId: ownerId, revokedAt: null }, - }); -} - -proveManageRoute("POST /api/cycle/feed-subscription", { - route: "/api/cycle/feed-subscription", - prepare: async (ownerId) => ({ ownerId: await enableCycle(ownerId) }), - ok: 201, - auditAction: "cycle.feed.create", - applied: async ({ ownerId }) => (await liveCycleFeeds(ownerId)).length === 1, -}); - -proveManageRoute("PATCH /api/cycle/feed-subscription", { - route: "/api/cycle/feed-subscription", - action: "PATCH", - prepare: async (ownerId) => { - await makeCycleFeed(ownerId); - return { ownerId }; - }, - body: () => ({ horizonCycles: 6, titleMode: "DESCRIPTIVE" }), - ok: 200, - auditAction: "cycle.feed.update", - applied: async ({ ownerId }) => { - const [feed] = await liveCycleFeeds(ownerId); - return feed?.horizonCycles === 6 && feed.titleMode === "DESCRIPTIVE"; - }, -}); - -proveManageRoute("DELETE /api/cycle/feed-subscription", { - route: "/api/cycle/feed-subscription", - action: "DELETE", - prepare: async (ownerId) => { - await makeCycleFeed(ownerId); - return { ownerId }; - }, - ok: 200, - auditAction: "cycle.feed.revoke", - applied: async ({ ownerId }) => (await liveCycleFeeds(ownerId)).length === 0, -}); - -proveManageRoute("POST /api/cycle/feed-subscription/rotate", { - route: "/api/cycle/feed-subscription/rotate", - prepare: async (ownerId) => ({ - ownerId, - previous: (await makeCycleFeed(ownerId)).id, - }), - ok: 201, - auditAction: "cycle.feed.rotate", - applied: async ({ ownerId, previous }) => { - const live = await liveCycleFeeds(ownerId); - return live.length === 1 && live[0].id !== previous; - }, -}); - proveManageRoute("POST /api/medications/categories", { route: "/api/medications/categories", prepare: async (ownerId) => ({ ownerId }), @@ -3089,7 +3020,7 @@ describe("the conditions the admissions were granted on", () => { describe("the complete MANAGE handler matrix", () => { it("registers one strict actor-and-effect driver for every admission", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(STRICT_DRIVER_KEYS.size).toBe(97); + expect(STRICT_DRIVER_KEYS.size).toBe(93); expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected); }); }); @@ -3101,6 +3032,6 @@ describe("the complete MANAGE handler matrix", () => { afterAll(() => { if (EFFECT_CASES_STARTED.size !== STRICT_DRIVER_KEYS.size) return; const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(REAL_EFFECT_KEYS.size).toBe(97); + expect(REAL_EFFECT_KEYS.size).toBe(93); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); });