From 2480a0138d3549c6a2a6c2f6e6fb8d8f32e35331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 6 Nov 2026 17:53:27 +0100 Subject: [PATCH] feat(cycle): add the calendar-feed credential table A record's cycle calendar subscription is a URL credential: one row per feed, only the HMAC of the raw hlf_ token stored, and at most one live feed per record through a partial unique index on user_id where revoked_at is null. Both foreign keys cascade, so deleting the record or the carrier who minted the feed ends it. The row is a credential everywhere else too. It stays out of the backup (a restore would bring back a revoked link), Delete All Data takes it on both sides, a credential remediation revokes the feeds on the record and the ones the account minted elsewhere, and a managed-profile handover claim revokes any feed a guardian minted. backupVerdict moves beside the plan into its own module so the plan stays the lists and their reasons. --- .../0494_cycle_calendar_feed/migration.sql | 85 +++++++++++++++++++ prisma/schema.prisma | 75 ++++++++++++++++ .../backup-plan-classification.test.ts | 2 +- src/lib/auth/__tests__/session.test.ts | 10 +++ src/lib/auth/credential-revocations.ts | 9 ++ src/lib/data-wipe/wipe-plan.ts | 6 ++ src/lib/export/backup-plan.ts | 19 +---- src/lib/export/backup-verdict.ts | 28 ++++++ src/lib/managed-profiles/lifecycle.ts | 19 +++++ 9 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 prisma/migrations/0494_cycle_calendar_feed/migration.sql create mode 100644 src/lib/export/backup-verdict.ts diff --git a/prisma/migrations/0494_cycle_calendar_feed/migration.sql b/prisma/migrations/0494_cycle_calendar_feed/migration.sql new file mode 100644 index 000000000..f967394ee --- /dev/null +++ b/prisma/migrations/0494_cycle_calendar_feed/migration.sql @@ -0,0 +1,85 @@ +-- Subscribable cycle forecast (#1350): one table holding the calendar-feed +-- credential of a record, and the enum for how its events are titled. +-- +-- The row is a URL credential. Only the HMAC of the raw `hlf_` token is +-- stored; the forecast itself is computed on every fetch and never persisted. +-- Revocation stamps `revoked_at` and never deletes, so the uniqueness that +-- matters (at most one LIVE feed per record) is a partial index, the shape +-- `account_grants_live_pair_key` uses. Prisma cannot express it, so it lives +-- 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 +-- cannot leave a link behind that nobody can revoke. +-- +-- One DO block, so the file is one statement that rolls back whole and the +-- foreign keys' lock wait on `users` is bounded. Only the new table and the +-- new type are created; nothing existing changes shape. Reversibility: +-- dropping the table and the type ends every feed URL; subscribers see 404 and +-- the owner mints again. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'cycle_calendar_feed_title_mode' + ) THEN + CREATE TYPE "cycle_calendar_feed_title_mode" AS ENUM ('DISCREET', 'DESCRIPTIVE'); + END IF; + + CREATE TABLE IF NOT EXISTS "cycle_calendar_feeds" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_by_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "token_prefix" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked_at" TIMESTAMP(3), + "last_fetched_at" TIMESTAMP(3), + "fetch_count" INTEGER NOT NULL DEFAULT 0, + "last_fetcher_ua" TEXT, + "body_hash" TEXT, + "content_changed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "horizon_cycles" INTEGER NOT NULL DEFAULT 3, + "history_cycles" INTEGER NOT NULL DEFAULT 0, + "include_fertile" BOOLEAN NOT NULL DEFAULT false, + "title_mode" "cycle_calendar_feed_title_mode" NOT NULL DEFAULT 'DISCREET', + + CONSTRAINT "cycle_calendar_feeds_pkey" PRIMARY KEY ("id"), + -- The ranges the options route validates, held again by the table so a + -- write that bypassed the route cannot widen the horizon. + CONSTRAINT "cycle_calendar_feeds_horizon_check" + CHECK ("horizon_cycles" BETWEEN 1 AND 6), + CONSTRAINT "cycle_calendar_feeds_history_check" + CHECK ("history_cycles" BETWEEN 0 AND 12) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS "cycle_calendar_feeds_token_hash_key" + ON "cycle_calendar_feeds" ("token_hash"); + + CREATE INDEX IF NOT EXISTS "cycle_calendar_feeds_user_id_created_at_idx" + ON "cycle_calendar_feeds" ("user_id", "created_at" DESC); + + CREATE INDEX IF NOT EXISTS "cycle_calendar_feeds_created_by_id_idx" + ON "cycle_calendar_feeds" ("created_by_id"); + + -- At most one live feed per record. Revoked rows fall out of the index, so a + -- rotate (revoke, then mint, in one transaction) never collides with itself. + CREATE UNIQUE INDEX IF NOT EXISTS "cycle_calendar_feeds_live_user_key" + ON "cycle_calendar_feeds" ("user_id") + WHERE "revoked_at" IS NULL; + + ALTER TABLE "cycle_calendar_feeds" + ADD CONSTRAINT "cycle_calendar_feeds_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE "cycle_calendar_feeds" + ADD CONSTRAINT "cycle_calendar_feeds_created_by_id_fkey" + FOREIGN KEY ("created_by_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index de35a4906..e26d5a222 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1166,6 +1166,12 @@ model User { // v1.15.0 — owner of a per-user custom CycleSymptom (NULL = the global // seeded catalogue). 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. + cycleCalendarFeeds CycleCalendarFeed[] @relation("CycleCalendarFeedSubject") + cycleCalendarFeedsMinted CycleCalendarFeed[] @relation("CycleCalendarFeedCreator") // v1.39 — the needs-based onboarding's answers and step ledger for THIS // record. 1:1, and keyed by record rather than by the person at the @@ -8328,6 +8334,75 @@ model CycleProfile { @@map("cycle_profiles") } +/// #1350 — how the feed titles its events. DISCREET shows a dot and names the +/// calendar "HealthLog"; DESCRIPTIVE names the period and the calendar. +enum CycleCalendarFeedTitleMode { + DISCREET + DESCRIPTIVE + + @@map("cycle_calendar_feed_title_mode") +} + +/// #1350 — a subscribable calendar feed of one record's cycle forecast. +/// +/// The row is a URL credential. Only the HMAC of the raw `hlf_` token is +/// stored (`hashToken`, the same key as `ApiToken.tokenHash`); the raw token is +/// returned once at mint and is unrecoverable afterwards. The feed is computed +/// live on every fetch from the record's own rows, so nothing about the +/// forecast is stored here. +/// +/// At most one live feed per record: a partial unique index on `user_id WHERE +/// revoked_at IS NULL`, written in the migration because Prisma cannot express +/// it. Revocation stamps `revokedAt` and never deletes, so the status read can +/// say when a link stopped working. Not in the backup: a restore would bring +/// back a link the owner revoked (see `backup-plan.ts`). +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. + createdById String @map("created_by_id") + + /// HMAC-SHA256 of the raw `hlf_` token. + tokenHash String @unique @map("token_hash") + /// The first 8 characters of the raw token, shown in settings so the owner + /// can tell which link is live. Not enough to rebuild it. + tokenPrefix String @map("token_prefix") + + createdAt DateTime @default(now()) @map("created_at") + revokedAt DateTime? @map("revoked_at") + + /// Fetch statistics, written fire-and-forget by the feed route and read by + /// the status endpoint ("last fetched 3 h ago by Google Calendar"). + lastFetchedAt DateTime? @map("last_fetched_at") + fetchCount Int @default(0) @map("fetch_count") + /// The last fetcher's User-Agent, truncated to 80 characters. + lastFetcherUa String? @map("last_fetcher_ua") + + /// SHA-256 of the last served body without its timestamps. When a fetch + /// builds a different body, `contentChangedAt` moves; both drive DTSTAMP, + /// LAST-MODIFIED and the ETag. + bodyHash String? @map("body_hash") + contentChangedAt DateTime @default(now()) @map("content_changed_at") + + /// Forecast cycles ahead (1..6). The feed caps itself further once the + /// band grows past ±10 days or half a cycle. + horizonCycles Int @default(3) @map("horizon_cycles") + /// Observed cycles included as history (0..12). + historyCycles Int @default(0) @map("history_cycles") + /// The fertile window of the next cycle, for TRYING_TO_CONCEIVE only. + includeFertile Boolean @default(false) @map("include_fertile") + titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode") + + user User @relation("CycleCalendarFeedSubject", fields: [userId], references: [id], onDelete: Cascade) + createdBy User @relation("CycleCalendarFeedCreator", fields: [createdById], references: [id], onDelete: Cascade) + + @@index([userId, createdAt(sort: Desc)]) + @@index([createdById]) + @@map("cycle_calendar_feeds") +} + /// One row per menstrual cycle (a period-start anchor + derived span). /// `isPredicted` rows are forward-minted forecasts; observed rows are /// confirmed from logged flow. Soft-deleted + syncVersion-bumped so the diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 648ccae7d..c62089f7d 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -9,8 +9,8 @@ import { DERIVED_MODELS, NOT_IN_BACKUP_MODELS, RAW_SQL_TABLES, - backupVerdict, } from "@/lib/export/backup-plan"; +import { backupVerdict } from "@/lib/export/backup-verdict"; /** * Every model in the schema has a written verdict about the backup. diff --git a/src/lib/auth/__tests__/session.test.ts b/src/lib/auth/__tests__/session.test.ts index 1384dcb54..158187643 100644 --- a/src/lib/auth/__tests__/session.test.ts +++ b/src/lib/auth/__tests__/session.test.ts @@ -52,6 +52,7 @@ vi.mock("@/lib/db", () => ({ trustedDevice: { deleteMany: vi.fn() }, mcpOAuthConnection: { updateMany: vi.fn() }, clinicianShareLink: { updateMany: vi.fn() }, + cycleCalendarFeed: { updateMany: vi.fn() }, stepUpElevation: { deleteMany: vi.fn() }, $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), }, @@ -584,6 +585,15 @@ describe("destroyAllSessions", () => { where: { userId: "user-rotated", revokedAt: null }, data: { revokedAt: expect.any(Date) }, }); + // The calendar feeds on the record and the ones this account minted on a + // record it manages are both URL credentials, and both end here. + expect(prisma.cycleCalendarFeed.updateMany).toHaveBeenCalledWith({ + where: { + OR: [{ userId: "user-rotated" }, { createdById: "user-rotated" }], + revokedAt: null, + }, + data: { revokedAt: expect.any(Date) }, + }); expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/auth/credential-revocations.ts b/src/lib/auth/credential-revocations.ts index 459936dd4..e4397ebb1 100644 --- a/src/lib/auth/credential-revocations.ts +++ b/src/lib/auth/credential-revocations.ts @@ -15,6 +15,7 @@ type CredentialStore = Pick< | "trustedDevice" | "mcpOAuthConnection" | "clinicianShareLink" + | "cycleCalendarFeed" | "stepUpElevation" >; @@ -56,6 +57,14 @@ export function credentialRevocations( where: { userId, revokedAt: null }, data: { revokedAt: now }, }), + // A cycle calendar feed is the same kind of door (#1350): a URL that + // serves the record's forecast to whoever holds it. Both the feeds on this + // record and the ones this account minted on a record it manages end here, + // so a remediation never leaves a link it cannot see. + db.cycleCalendarFeed.updateMany({ + where: { OR: [{ userId }, { createdById: userId }], revokedAt: null }, + data: { revokedAt: now }, + }), db.stepUpElevation.deleteMany({ where: { userId } }), ]; } diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 50088cced..537915add 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -223,6 +223,10 @@ export const WIPE_MODELS = [ // `resolveShareGateState` both resolve an unknown `tokenHash` to `null`, and // null is the same blunt 404 a revoked link gets. "ClinicianShareLink", + // #1350 — the cycle calendar feed, on both sides (see WIPE_OWNER_FIELDS): + // the feed on this record, and any this account minted on a record it + // manages. Deleting the row is the revocation, as for the share link. + "CycleCalendarFeed", // v1.36.0 — account grants, on BOTH sides (see WIPE_OWNER_FIELDS): the ones // this account made over its own record, and the ones it holds over someone // else's. @@ -587,6 +591,8 @@ export const WIPE_OWNER_FIELDS: Readonly> = { NotificationEvent: ["recordUserId"], NotificationEgressAuthorization: ["recordUserId", "recipientUserId"], ManagedProfileHandover: ["profileId", "createdById"], + // #1350 — the record the feed serves, and the carrier who minted it. + CycleCalendarFeed: ["userId", "createdById"], // Both sides. Wiping only the grantor side would leave the account still // holding read access to other people's records after it asked for // everything of its own to be deleted. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 3392a7f3b..2ab645657 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -792,6 +792,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { "Live authorization over another person's health record. A backup is a snapshot of DATA, and rolling data back is a decision the owner's admin makes; rolling an authorization back is a decision nobody makes — the grant an owner revoked on Tuesday would come back alive out of Monday's file, silently, with no notification and nobody aware that access resumed. Every credential-shaped row in this file is excluded for that reason (ApiToken, TrustedDevice, StepUpElevation, UserKnownDevice) and a share link for the closest one (ClinicianShareLink). The cost is stated rather than hidden: after a disaster restore, sharing is OFF and the owner has to invite again and the delegate has to accept again. That is the fail-safe direction — access lost, never access resumed — and re-consenting after a restore is the correct amount of ceremony for handing someone your health record a second time.", ClinicianShareLink: "A live URL handed to a practice. Restoring it would resurrect a link the account may have revoked deliberately — and it points at a host that may not be this one.", + CycleCalendarFeed: + "A live calendar-feed URL (#1350): a credential a calendar provider polls, stored only as its hash. Restoring it would bring back a link the owner may have revoked or rotated, and it names a host that may not be this one. After a restore the owner turns the subscription on again and re-adds the new link.", ClinicianShareLinkDocument: "Names which documents a share link exposed. Without the link it grants nothing, and with it, it would re-expose files through a URL the account may have revoked.", @@ -834,20 +836,3 @@ export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [ "OidcNativeHandoff", "InviteRedemption", ]; - -export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH"; - -/** - * The verdict for a model, or `null` when the schema carries a model this file - * has never ruled on. The guard turns that `null` into a failing test, which - * is the whole point: a new model gets a decision, not a default. - */ -export function backupVerdict(model: string): BackupVerdict | null { - if ((BACKED_UP_MODELS as readonly string[]).includes(model)) { - return "BACKED_UP"; - } - if (model in DERIVED_MODELS) return "DERIVED"; - if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP"; - if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH"; - return null; -} diff --git a/src/lib/export/backup-verdict.ts b/src/lib/export/backup-verdict.ts new file mode 100644 index 000000000..6fbaf8ea6 --- /dev/null +++ b/src/lib/export/backup-verdict.ts @@ -0,0 +1,28 @@ +/** + * The backup verdict for one schema model, read off the lists in + * `./backup-plan.ts`. Kept beside it rather than in it so the plan stays the + * lists and their reasons, and the one question asked of them lives here. + */ +import { + AUTH_MODELS_OUT_OF_SCOPE, + BACKED_UP_MODELS, + DERIVED_MODELS, + NOT_IN_BACKUP_MODELS, +} from "./backup-plan"; + +export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH"; + +/** + * The verdict for a model, or `null` when the schema carries a model this file + * has never ruled on. The guard turns that `null` into a failing test, which + * is the whole point: a new model gets a decision, not a default. + */ +export function backupVerdict(model: string): BackupVerdict | null { + if ((BACKED_UP_MODELS as readonly string[]).includes(model)) { + return "BACKED_UP"; + } + if (model in DERIVED_MODELS) return "DERIVED"; + if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP"; + if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH"; + return null; +} diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 8b91834b0..50aec84b0 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -555,6 +555,25 @@ export async function clearManagedProfileMarker( where: { id: profile.id }, data: { managedProfileAt: null }, }); + // #1350 — a calendar feed a Guardian minted on the profile ends with the + // handover. The new owner never saw the disclosure that came with it, + // and a Guardian who keeps MANAGE afterwards would otherwise keep a + // standing link to a grown-up's cycle dates nobody re-agreed to. + const revoked = await client.cycleCalendarFeed.updateMany({ + where: { + userId: profile.id, + createdById: { not: profile.id }, + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }); + if (revoked.count > 0) { + await auditLog("cycle.feed.revoke.handover", { + userId: profile.id, + details: { revoked: revoked.count }, + client, + }); + } }); if (tx) { await clear(tx);