mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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$;
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } }),
|
||||
];
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user