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

@@ -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);