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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-06 18:03:27 +01:00
1 parent c0470df14c
commit 2480a0138d
9 files changed
+235 -18

No files matched your search

@@ -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$;
+75
View File
@@ -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
@@ -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.
+10
View File
@@ -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<unknown>[]) => 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);
});
});
+9
View File
@@ -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 } }),
];
}
+6
View File
@@ -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<Record<string, readonly string[]>> = {
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.
+2 -17
View File
@@ -792,6 +792,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
"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;
}
+28
View File
@@ -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;
}
+19
View File
@@ -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);