diff --git a/prisma/migrations/0199_v123_mfa_columns/migration.sql b/prisma/migrations/0199_v123_mfa_columns/migration.sql new file mode 100644 index 000000000..f26a7b3c2 --- /dev/null +++ b/prisma/migrations/0199_v123_mfa_columns/migration.sql @@ -0,0 +1,24 @@ +-- v1.23 — account-security columns (second-factor substrate). +-- +-- Additive, nullable / defaulted columns only — existing rows need no +-- backfill. The TOTP shared secret (`totp_secret_encrypted`) is AES-256-GCM +-- ciphertext like every other `*_encrypted` column (registered in the +-- encrypted-columns registry + the rotation script). `totp_confirmed_at` +-- marks the pending->active promotion; `mfa_enforced` / +-- `passkey_upgrade_nudge_dismissed` are per-account flags later waves read. +-- +-- `sessions.mfa_verified_at` is the step-up freshness stamp; `last_active_at` +-- backs the user-facing active-session list. `passkeys.last_used_at` is set on +-- each successful assertion so the management UI can surface "last used". +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "totp_secret_encrypted" TEXT, + ADD COLUMN IF NOT EXISTS "totp_confirmed_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "mfa_enforced" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "passkey_upgrade_nudge_dismissed" BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE "sessions" + ADD COLUMN IF NOT EXISTS "mfa_verified_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "last_active_at" TIMESTAMP(3); + +ALTER TABLE "passkeys" + ADD COLUMN IF NOT EXISTS "last_used_at" TIMESTAMP(3); diff --git a/prisma/migrations/0200_v123_mfa_tables/migration.sql b/prisma/migrations/0200_v123_mfa_tables/migration.sql new file mode 100644 index 000000000..3cdb49488 --- /dev/null +++ b/prisma/migrations/0200_v123_mfa_tables/migration.sql @@ -0,0 +1,69 @@ +-- v1.23 — second-factor (MFA) tables. +-- +-- Three new stores backing the TOTP / WebAuthn-second-factor / recovery-code +-- flows later waves build on. Phase F lands the empty tables only — no +-- behaviour yet. +-- +-- mfa_challenges single-use step-up ticket minted after a valid +-- password when the account has a second factor. +-- The "password OK, awaiting factor 2" state lives +-- here, never in a half-built session, so no +-- session/token is issued before factor 2 passes. +-- Only the ticket HASH is stored (ticket_hash). +-- mfa_recovery_codes one row per single-use recovery code, Argon2id- +-- hashed at rest; used_at burns it. +-- webauthn_mfa_credentials second-factor security keys, kept SEPARATE from +-- `passkeys` (the passwordless-primary store) — +-- different semantics, ceremony, management UI. +-- Mirrors the passkeys field encoding. + +CREATE TABLE "mfa_challenges" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "ticket_hash" TEXT NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "expires_at" TIMESTAMP(3) NOT NULL, + "consumed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "mfa_challenges_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "mfa_challenges_ticket_hash_key" ON "mfa_challenges"("ticket_hash"); +CREATE INDEX "mfa_challenges_user_id_idx" ON "mfa_challenges"("user_id"); +-- The verify path and the expiry sweep both filter on expires_at. +CREATE INDEX "mfa_challenges_expires_at_idx" ON "mfa_challenges"("expires_at"); + +CREATE TABLE "mfa_recovery_codes" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "code_hash" TEXT NOT NULL, + "used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "mfa_recovery_codes_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "mfa_recovery_codes_user_id_idx" ON "mfa_recovery_codes"("user_id"); + +CREATE TABLE "webauthn_mfa_credentials" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL DEFAULT 'Security key', + "credential_id" TEXT NOT NULL, + "credential_public_key" BYTEA NOT NULL, + "counter" BIGINT NOT NULL DEFAULT 0, + "transports" TEXT[], + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_used_at" TIMESTAMP(3), + + CONSTRAINT "webauthn_mfa_credentials_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "webauthn_mfa_credentials_credential_id_key" ON "webauthn_mfa_credentials"("credential_id"); +CREATE INDEX "webauthn_mfa_credentials_user_id_idx" ON "webauthn_mfa_credentials"("user_id"); + +ALTER TABLE "mfa_challenges" ADD CONSTRAINT "mfa_challenges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "mfa_recovery_codes" ADD CONSTRAINT "mfa_recovery_codes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "webauthn_mfa_credentials" ADD CONSTRAINT "webauthn_mfa_credentials_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0201_v123_note_encryption/migration.sql b/prisma/migrations/0201_v123_note_encryption/migration.sql new file mode 100644 index 000000000..c55a83c6a --- /dev/null +++ b/prisma/migrations/0201_v123_note_encryption/migration.sql @@ -0,0 +1,28 @@ +-- v1.23 — encrypt free-text health notes at rest. +-- +-- Adds the AES-256-GCM ciphertext columns for the two free-text health-note +-- fields that were still plaintext (`mood_entries.note`, `measurements.notes`). +-- +-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text +-- encrypted-note convention already in the schema — illness_day_logs.note_ +-- encrypted, cycle_day_logs.notes_encrypted (string-kind), labs.note_encrypted, +-- coach_*.{...}_encrypted are all the shared-codec shape (the `encrypt()` +-- ciphertext string stored UTF-8 as `bytea`). `bytea` is chosen here because +-- the comparable note columns (IllnessDayLog / LabResult) use it and the +-- rotation script's `rotateBytesColumn` already covers that shape with least +-- friction. Both columns are registered in ENCRYPTED_COLUMNS + the rotation +-- script in the same change. +-- +-- This migration is ADDITIVE ONLY. The legacy plaintext columns +-- (`mood_entries.note`, `measurements.notes`) are intentionally NOT dropped +-- here: an idempotent, transactional, fail-closed boot-time job encrypts the +-- existing rows (plaintext -> ciphertext, then nulls the plaintext) so the +-- read path can fall back to plaintext for not-yet-migrated rows. Dropping the +-- plaintext columns is a deliberate FOLLOW-UP release, once the backfill +-- reports zero remaining plaintext-but-no-ciphertext rows — the same boundary +-- the encryption-key-rotation playbook uses before dropping a legacy key. +ALTER TABLE "mood_entries" + ADD COLUMN IF NOT EXISTS "note_encrypted" BYTEA; + +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "notes_encrypted" BYTEA; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dfcb5020a..9aaf0ae5b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -128,8 +128,8 @@ model User { // running Nightscout on a private/LAN network can flip // `nightscoutAllowPrivateHost` to opt that instance past the public-host // requirement explicitly. - nightscoutUrlEncrypted String? @map("nightscout_url_encrypted") - nightscoutTokenEncrypted String? @map("nightscout_token_encrypted") + nightscoutUrlEncrypted String? @map("nightscout_url_encrypted") + nightscoutTokenEncrypted String? @map("nightscout_token_encrypted") nightscoutAllowPrivateHost Boolean @default(false) @map("nightscout_allow_private_host") // v1.17.0 — Polar OAuth credentials (F4, per-user, encrypted at app level). @@ -175,10 +175,10 @@ model User { // { "WEIGHT": { "min": 70, "max": 85 }, // "BLOOD_PRESSURE_SYS": { "min": 110, "max": 130 }, ... } // Null or missing metric → use computed default. - thresholdsJson Json? @map("thresholds_json") + thresholdsJson Json? @map("thresholds_json") // Per-user dashboard layout — widget visibility + ordering. // Shape: { "widgets": [{ "id": "weight", "visible": true, "order": 0 }, ...], "version": 1 } - dashboardWidgetsJson Json? @map("dashboard_widgets_json") + dashboardWidgetsJson Json? @map("dashboard_widgets_json") // Per-user insights tile layout — tile visibility + ordering for the // `/insights` surface. Mirrors `dashboardWidgetsJson` so the iOS // client and the web Settings UI can persist insights tile order + @@ -187,7 +187,7 @@ model User { // endpoint never lazy-writes — the column only carries data once // the user has explicitly saved a layout. // Shape: { "tiles": [{ "id": "blutdruck", "visible": true, "order": 0 }, ...], "version": 1 } - insightsLayoutJson Json? @map("insights_layout_json") + insightsLayoutJson Json? @map("insights_layout_json") // Per-user medications list presentation — the card/table view choice // plus the user-defined manual medication order shared by BOTH views. // Follows the per-surface-column convention of `dashboardWidgetsJson` / @@ -195,23 +195,23 @@ model User { // The GET endpoint never lazy-writes — the column only carries data // once the user has explicitly toggled the view or saved an order. // Shape: { "version": 1, "view": "table", "order": ["", ...] } - medicationListLayoutJson Json? @map("medication_list_layout_json") + medicationListLayoutJson Json? @map("medication_list_layout_json") // v1.17.0 — per-user mood-tag presentation: group order + per-group tag // placement (catalogue tags "moved" into a group live here only — their // `categoryId` never changes). Display-only, preserve-when-absent PUT, // unknown keys dropped at read. Null = seeded defaults. // Shape: { "groupOrder": ["", ...], // "placements": { "": ["", ...] } } - moodTagLayoutJson Json? @map("mood_tag_layout_json") + moodTagLayoutJson Json? @map("mood_tag_layout_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. - glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" + glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in. // When `true`, the glucose target resolver applies the tighter ADA glycemic // GOAL bands (fasting 80–130, postprandial < 180) instead of the general // non-diabetic normal bands. NEVER auto-inferred from values, NEVER a // diagnosis — purely a user-declared preference that selects which target // band the user is judged against. Default `false`. - hasDiabetes Boolean @default(false) @map("has_diabetes") + hasDiabetes Boolean @default(false) @map("has_diabetes") // v1.18.10 — opt-in local (in-browser) OCR for lab-report scans. When `true`, // a user whose AI provider cannot read images (e.g. ChatGPT-OAuth/Codex, a // text-only model) can still scan a report: the image is OCR'd in the browser @@ -219,31 +219,31 @@ model User { // provider for structuring. Less accurate than native vision — the mandatory // review screen remains the safety backstop. Default `false`; vision-capable // users keep the higher-accuracy native path and never pay the WASM download. - labsLocalOcrEnabled Boolean @default(false) @map("labs_local_ocr_enabled") + labsLocalOcrEnabled Boolean @default(false) @map("labs_local_ocr_enabled") // v1.7.0 unit pref — global metric/imperial display preference. // Canonical storage stays SI; this only selects the display-time // transform branch (km/h vs mph, km vs mi). Null = default "metric". - unitPreference String? @map("unit_preference") // "metric" | "imperial" | null = default "metric" + unitPreference String? @map("unit_preference") // "metric" | "imperial" | null = default "metric" // Hour-cycle display preference. AUTO follows the locale convention // (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour. // Display-time only — canonical storage stays UTC instants. - timeFormat TimeFormatPreference @default(AUTO) @map("time_format") + timeFormat TimeFormatPreference @default(AUTO) @map("time_format") // Date display preference. AUTO follows the active locale convention // (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY pins day-month-year, // MDY pins month-day-year, YMD pins ISO yyyy-MM-dd. Display-time only — // canonical storage stays UTC instants. - dateFormat DateFormatPreference @default(AUTO) @map("date_format") + dateFormat DateFormatPreference @default(AUTO) @map("date_format") // AI provider selection for insights. Null = OPENAI fallback. - aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH" - aiModel String? @map("ai_model") - aiBaseUrl String? @map("ai_base_url") - aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted") - aiLocalKeyEncrypted String? @map("ai_local_key_encrypted") + aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH" + aiModel String? @map("ai_model") + aiBaseUrl String? @map("ai_base_url") + aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted") + aiLocalKeyEncrypted String? @map("ai_local_key_encrypted") // v1.4.3: per-user OpenAI API key for the OPENAI provider — previously // the schema only stored Anthropic and Local keys, leaving OPENAI without // a credential surface. Encrypted with the active ENCRYPTION_KEYS entry // before storage. - aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted") + aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted") // v1.4.16 phase B5b — ordered fallback chain for multi-provider AI // redundancy. Each entry is `{ providerType, priority, enabled }`, @@ -549,51 +549,67 @@ model User { avatarContentType String? @map("avatar_content_type") avatarUpdatedAt DateTime? @map("avatar_updated_at") + // v1.23 — second-factor (TOTP) + account-security flags. The TOTP shared + // secret rides the same AES-256-GCM at-rest envelope as every other + // credential (registered in ENCRYPTED_COLUMNS + the rotation script); + // `totpConfirmedAt` marks the pending→active promotion. `mfaEnforced` and + // `passkeyUpgradeNudgeDismissed` are per-account flags later waves read. + totpSecretEncrypted String? @map("totp_secret_encrypted") + totpConfirmedAt DateTime? @map("totp_confirmed_at") + mfaEnforced Boolean @default(false) @map("mfa_enforced") + passkeyUpgradeNudgeDismissed Boolean @default(false) @map("passkey_upgrade_nudge_dismissed") + // Relations - passkeys Passkey[] - sessions Session[] - challenges AuthChallenge[] - measurements Measurement[] - medications Medication[] - intakeEvents MedicationIntakeEvent[] - apiTokens ApiToken[] - refreshTokens RefreshToken[] - auditLogs AuditLog[] - withingsConnection WithingsConnection? + passkeys Passkey[] + sessions Session[] + challenges AuthChallenge[] + // v1.23 — second-factor stores (kept separate from `passkeys`, which is the + // passwordless-primary credential set; an MFA credential is meaningless + // without the password). + mfaChallenges MfaChallenge[] + mfaRecoveryCodes MfaRecoveryCode[] + webauthnMfaCredentials WebauthnMfaCredential[] + measurements Measurement[] + medications Medication[] + intakeEvents MedicationIntakeEvent[] + apiTokens ApiToken[] + refreshTokens RefreshToken[] + auditLogs AuditLog[] + withingsConnection WithingsConnection? // v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the // Withings OAuth state check. The connect route mints a row with a // 10-minute TTL; the callback resolves the user via this relation // instead of parsing the cookie value, closing the L-1 audit gap // where the legacy `${user.id}:${nonce}` shape leaked the user id // into request logs / network captures. - withingsOAuthStates WithingsOAuthState[] + withingsOAuthStates WithingsOAuthState[] // v1.11.0 — WHOOP integration. Mirrors the Withings connection (1:1) + // OAuth-state ledger (1:N) relations. - whoopConnection WhoopConnection? - whoopOAuthStates WhoopOAuthState[] - whoopConnectTickets WhoopConnectTicket[] + whoopConnection WhoopConnection? + whoopOAuthStates WhoopOAuthState[] + whoopConnectTickets WhoopConnectTicket[] // v1.12.0 — Fitbit/Pixel integration. Mirrors the WHOOP connection (1:1) + // OAuth-state ledger (1:N) relations. - fitbitConnection FitbitConnection? - fitbitOAuthStates FitbitOAuthState[] - moodEntries MoodEntry[] - moodTagsCustom MoodTag[] @relation("UserMoodTags") - moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") - moodTagsHidden MoodTagHidden[] - notificationChannels NotificationChannel[] - pushSubscriptions PushSubscription[] - dataBackups DataBackup[] - achievements UserAchievement[] - feedback Feedback[] - recommendationFeedback RecommendationFeedback[] - idempotencyKeys IdempotencyKey[] - devices Device[] - integrationStatuses IntegrationStatus[] - coachConversations CoachConversation[] - coachFacts CoachFact[] - coachPlans CoachPlan[] - coachReminders CoachReminder[] - coachUsage CoachUsage[] + fitbitConnection FitbitConnection? + fitbitOAuthStates FitbitOAuthState[] + moodEntries MoodEntry[] + moodTagsCustom MoodTag[] @relation("UserMoodTags") + moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") + moodTagsHidden MoodTagHidden[] + notificationChannels NotificationChannel[] + pushSubscriptions PushSubscription[] + dataBackups DataBackup[] + achievements UserAchievement[] + feedback Feedback[] + recommendationFeedback RecommendationFeedback[] + idempotencyKeys IdempotencyKey[] + devices Device[] + integrationStatuses IntegrationStatus[] + coachConversations CoachConversation[] + coachFacts CoachFact[] + coachPlans CoachPlan[] + coachReminders CoachReminder[] + coachUsage CoachUsage[] // v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings // getworkouts / future Strava). Schema lands now so the v1.5 iOS // session can build against a stable DTO. Routes hang off Workout @@ -716,29 +732,29 @@ model User { // does not warrant the encrypted column treatment. Optional; an unset // field collapses the Coverage payor identifier exactly like an // unset `insurerName` collapses its name. - insurerIkNumber String? @map("insurer_ik_number") + insurerIkNumber String? @map("insurer_ik_number") // v1.15.0 — cycle tracking. The CycleProfile is a 1:1 settings row // (lazily upserted on first cycle-surface access); MenstrualCycle + // CycleDayLog are the soft-deleted, sync-feed-bearing capture rows; // CyclePrediction is a 1:1 cached forecast (debounced regenerate). - cycleProfile CycleProfile? - menstrualCycles MenstrualCycle[] - cycleDayLogs CycleDayLog[] - cyclePrediction CyclePrediction? + cycleProfile CycleProfile? + menstrualCycles MenstrualCycle[] + cycleDayLogs CycleDayLog[] + cyclePrediction CyclePrediction? // v1.15.0 — owner of a per-user custom CycleSymptom (NULL = the global // seeded catalogue). - cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms") + cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms") // v1.15.20 — user-authored "about me" context for the AI surfaces // (1:1, encrypted free text). See `UserHealthProfile`. - healthProfile UserHealthProfile? + healthProfile UserHealthProfile? // v1.15.20 — registration invite tokens (closed-registration flow). // `InviteCreator` = admin who minted the invite; `InviteConsumer` = // the account that registered through it (last consumer wins on a // multi-use invite). - invitesCreated InviteToken[] @relation("InviteCreator") - invitesUsed InviteToken[] @relation("InviteConsumer") + invitesCreated InviteToken[] @relation("InviteCreator") + invitesUsed InviteToken[] @relation("InviteConsumer") // v1.16.0 — per-signup invite redemption ledger (multi-use history). inviteRedemptions InviteRedemption[] @relation("InviteRedemptions") @@ -746,16 +762,19 @@ model User { } model Passkey { - id String @id @default(cuid()) - userId String @map("user_id") - name String @default("Passkey") - credentialId String @unique @map("credential_id") - credentialPublicKey Bytes @map("credential_public_key") - counter BigInt @default(0) - credentialDeviceType String @map("credential_device_type") - credentialBackedUp Boolean @default(false) @map("credential_backed_up") + id String @id @default(cuid()) + userId String @map("user_id") + name String @default("Passkey") + credentialId String @unique @map("credential_id") + credentialPublicKey Bytes @map("credential_public_key") + counter BigInt @default(0) + credentialDeviceType String @map("credential_device_type") + credentialBackedUp Boolean @default(false) @map("credential_backed_up") transports String[] // e.g. ["usb", "ble", "nfc", "internal"] - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(now()) @map("created_at") + // v1.23 — stamped each time the credential's counter is bumped on a + // successful assertion, so the management UI can surface "last used". + lastUsedAt DateTime? @map("last_used_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -763,12 +782,17 @@ model Passkey { } model Session { - id String @id @default(cuid()) - userId String @map("user_id") - expiresAt DateTime @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") - ipAddress String? @map("ip_address") - userAgent String? @map("user_agent") + id String @id @default(cuid()) + userId String @map("user_id") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + // v1.23 — step-up freshness stamp. Set when the session completes a second + // factor (or re-confirms it); `requireFreshMfa` (a later wave) reads it. + mfaVerifiedAt DateTime? @map("mfa_verified_at") + // v1.23 — sliding "last seen" for the user-facing active-session list. + lastActiveAt DateTime? @map("last_active_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -779,6 +803,72 @@ model Session { @@map("sessions") } +// ─── v1.23 second-factor (MFA) substrate ─────────────────── +// These tables back the TOTP / WebAuthn-second-factor / recovery-code flows +// that later waves build on. Phase F lands the empty stores only. + +// Single-use step-up ticket minted after a valid password when the account +// has a second factor enabled. The partial "password OK, awaiting factor 2" +// state lives in this row — never in a half-built Session — so no session or +// token is issued before the second factor passes. The ticket is presented as +// an opaque value; only its hash is stored. +model MfaChallenge { + id String @id @default(cuid()) + userId String @map("user_id") + // Which factor the ticket is being exercised against ("totp" | "webauthn" | + // "recovery_code"). A free-form column; the verify route validates it. + kind String + ticketHash String @unique @map("ticket_hash") // hashToken(ticket) + attempts Int @default(0) + expiresAt DateTime @map("expires_at") + consumedAt DateTime? @map("consumed_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + // The verify path and the expiry sweep both filter on `expiresAt`. + @@index([expiresAt]) + @@map("mfa_challenges") +} + +// One row per single-use recovery code. The code is Argon2id-hashed at rest +// (same primitive as the password); `usedAt` burns it. Regeneration deletes the +// prior set in a transaction. +model MfaRecoveryCode { + id String @id @default(cuid()) + userId String @map("user_id") + codeHash String @map("code_hash") // Argon2id + usedAt DateTime? @map("used_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("mfa_recovery_codes") +} + +// Second-factor WebAuthn credential (typically a roaming security key), kept +// SEPARATE from `Passkey`: a `Passkey` row is a passwordless-primary login +// credential, whereas an MFA credential only ever runs after the password has +// identified the account. Mirrors the `Passkey` field encoding. +model WebauthnMfaCredential { + id String @id @default(cuid()) + userId String @map("user_id") + name String @default("Security key") + credentialId String @unique @map("credential_id") + credentialPublicKey Bytes @map("credential_public_key") + counter BigInt @default(0) + transports String[] // e.g. ["usb", "ble", "nfc", "internal"] + createdAt DateTime @default(now()) @map("created_at") + lastUsedAt DateTime? @map("last_used_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("webauthn_mfa_credentials") +} + model AuthChallenge { id String @id @default(cuid()) userId String? @map("user_id") // null for registration @@ -1074,8 +1164,8 @@ enum SleepStage { } model Measurement { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") type MeasurementType value Float // v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated row. @@ -1086,28 +1176,32 @@ model Measurement { // samples. NULL for every other row (per-sample readings, per-day // cumulative `stats:` totals, manual entries) — the series reader hands // them through only for the heart-rate kind. - valueMin Float? @map("value_min") - valueMax Float? @map("value_max") + valueMin Float? @map("value_min") + valueMax Float? @map("value_max") unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius - source MeasurementSource @default(MANUAL) - measuredAt DateTime @map("measured_at") - notes String? // encrypted at app level if sensitive + source MeasurementSource @default(MANUAL) + measuredAt DateTime @map("measured_at") + notes String? // LEGACY plaintext — superseded by `notesEncrypted` (v1.23). New writes go to the encrypted column and null this; the boot-time backfill migrates the remaining rows. Column drop is a follow-up release once the backfill reports zero remaining. + // v1.23 — free-text measurement note, AES-256-GCM at rest (Bytes, matching + // the IllnessDayLog / CycleDayLog / LabResult note convention). Registered + // in ENCRYPTED_COLUMNS + the rotation script. + notesEncrypted Bytes? @map("notes_encrypted") // External-system dedup key. Withings used `grpid` (numeric); v1.4.23 // adds Apple Health which uses `HKSample.uuid` (string). NULL for // manual entries — Postgres treats NULLs as distinct in unique // indexes, so two manual rows with the same (user, type, source) are // still allowed. - externalId String? @map("external_id") + externalId String? @map("external_id") // v1.4.23 — opaque marker for the source's schema version (HealthKit // sample's `metadata.HKWasUserEntered`, Withings model code, …). // Stored as a string so the ingest path can replay it back unchanged. - externalSourceVersion String? @map("external_source_version") + externalSourceVersion String? @map("external_source_version") // Only set for BLOOD_GLUCOSE measurements. NULL for all others. - glucoseContext GlucoseContext? @map("glucose_context") + glucoseContext GlucoseContext? @map("glucose_context") // v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL // only for `type = SLEEP_DURATION`. Mirrors the glucoseContext // pattern. A single night produces ~5 rows (one per stage). - sleepStage SleepStage? @map("sleep_stage") + sleepStage SleepStage? @map("sleep_stage") // v1.10.0 — categorical events (WX-B). The device's own classification // verdict / severity for an EVENT row. NULL for every continuous // measurement; non-NULL only for the categorical EVENT `MeasurementType`s. @@ -1127,7 +1221,7 @@ model Measurement { // NULL as `unknown`. Unique constraints intentionally do not // include `deviceType` so two ingest paths from the same source // still dedup on (type, source, externalId). - deviceType String? @map("device_type") + deviceType String? @map("device_type") // v1.4.30 — iOS SyncMode foundation (R-E C-2). Monotonically // incremented on every server-side mutation of the row. iOS uses // it for last-writer-wins reconciliation when two devices write @@ -1135,18 +1229,18 @@ model Measurement { // update path is responsible for bumping it. Existing rows // start at the default after the additive migration so legacy // ingest keeps round-tripping unchanged. - syncVersion Int @default(1) @map("sync_version") + syncVersion Int @default(1) @map("sync_version") // v1.4.30 — soft-delete column. The DELETE path on the // by-external-ids endpoint flips this rather than removing the row // so paired iOS clients can reconcile deletions without losing the // audit trail. NULL = live row; non-NULL = tombstoned at the // recorded instant. The unique-by-externalId index keeps both // states distinguishable for the bulk-backfill UPSERT path. - deletedAt DateTime? @map("deleted_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) // v1.19.0 — optional back-relation to the full ECG waveform recording(s) // paired with this EVENT row. SET NULL on the FK side keeps the trace if // the EVENT row is re-keyed / removed. @@ -1245,31 +1339,31 @@ model Measurement { // RENDERING UI IS OUT OF SCOPE FOR v1.19.0 — this is ingest-only. A trace // view (and any analytics over the waveform) is a follow-up. model EcgRecording { - id String @id @default(cuid()) - userId String @map("user_id") - source MeasurementSource @default(WITHINGS) + id String @id @default(cuid()) + userId String @map("user_id") + source MeasurementSource @default(WITHINGS) /// Source's stable per-recording id (Withings `signalid`, or `ts-`). - externalRecordingId String @map("external_recording_id") + externalRecordingId String @map("external_recording_id") /// When the strip was recorded on-device (source timestamp). - recordedAt DateTime @map("recorded_at") + recordedAt DateTime @map("recorded_at") /// AES-256-GCM ciphertext of the JSON-encoded micro-volt sample array. - waveformEncrypted Bytes @map("waveform_encrypted") + waveformEncrypted Bytes @map("waveform_encrypted") /// Sampling rate of the signal in Hz (sample index → time offset). - samplingFrequency Int @map("sampling_frequency") + samplingFrequency Int @map("sampling_frequency") /// Number of samples in the (decrypted) waveform array. - sampleCount Int @map("sample_count") + sampleCount Int @map("sample_count") /// `sampleCount / samplingFrequency` in seconds; NULL when frequency is 0. - durationSeconds Float? @map("duration_seconds") + durationSeconds Float? @map("duration_seconds") /// Recording lead / pathway label when the source reports it (else NULL). - lead String? + lead String? /// Source-reported average heart rate (BPM) for the strip, when present. - averageHeartRate Int? @map("average_heart_rate") + averageHeartRate Int? @map("average_heart_rate") /// Snapshot of the device AFib verdict for the paired EVENT row. rhythmClassification RhythmClassification? @map("rhythm_classification") /// FK to the IRREGULAR_RHYTHM_NOTIFICATION Measurement EVENT row, when known. - measurementId String? @map("measurement_id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + measurementId String? @map("measurement_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) measurement Measurement? @relation(fields: [measurementId], references: [id], onDelete: SetNull) @@ -1516,20 +1610,20 @@ model WorkoutSamples { // table keeps it cleanly server-internal. Idempotent on `(userId, day)` so // a re-fired nightly tick overwrites the day's cache row in place. model StrainTrimpCache { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// The scored UTC calendar day (`YYYY-MM-DD`), matching the score row's day key. - day String + day String /// Day-total Banister TRIMP for the day (0 when no usable HR series). - dayTrimp Float @map("day_trimp") + dayTrimp Float @map("day_trimp") /// EWMA-smoothed personal reference TRIMP in effect for this day's score. - refPersonal Float @map("ref_personal") + refPersonal Float @map("ref_personal") /// Which anchor produced this day's score: `personal` once enough personal /// training history accrued, `population` during cold start. - anchor String + anchor String /// Distinct training days (TRIMP > 0) seen in the chronic window — drives /// the cold-start gate + the confidence label. - trainingDays Int @map("training_days") + trainingDays Int @map("training_days") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1631,10 +1725,10 @@ enum MedicationScheduleType { } model Medication { - id String @id @default(cuid()) - userId String @map("user_id") - name String - dose String // e.g. "10mg" + id String @id @default(cuid()) + userId String @map("user_id") + name String + dose String // e.g. "10mg" /// v1.4.25 W4d — treatment-class discriminator. GENERIC for the /// vast majority of meds; GLP1 unlocks injection-site rotation, /// dose-titration history, pen inventory, plateau detection, and @@ -1642,11 +1736,11 @@ model Medication { /// not `category` to avoid clashing with the existing /// `medication_categories` side-table (clinical taxonomy) that the /// UI continues to read. - treatmentClass MedicationCategory @default(GENERIC) @map("treatment_class") + treatmentClass MedicationCategory @default(GENERIC) @map("treatment_class") /// Doses per pen/vial — only meaningful for inventory-tracked meds /// (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose /// pen = 1). NULL = inventory tracking off. - dosesPerUnit Int? @map("doses_per_unit") + dosesPerUnit Int? @map("doses_per_unit") /// v1.16.10 — units consumed per dose. The inventory items count UNITS /// (tablets, ampoules, puffs); a single dose may consume several of /// them (2 × 2 mg tablets for a 4 mg dose). The intake consumption @@ -1657,21 +1751,21 @@ model Medication { /// set of common fractions; the column stores their decimal value /// (¼=0.25, ⅓≈0.3333, ½=0.5, ⅔≈0.6667, ¾=0.75) plus whole numbers. /// Existing integer rows cast losslessly. - unitsPerDose Decimal @default(1) @map("units_per_dose") @db.Decimal(10, 4) - active Boolean @default(true) - notificationsEnabled Boolean @default(true) @map("notifications_enabled") - pausedAt DateTime? @map("paused_at") - snoozedUntil DateTime? @map("snoozed_until") + unitsPerDose Decimal @default(1) @map("units_per_dose") @db.Decimal(10, 4) + active Boolean @default(true) + notificationsEnabled Boolean @default(true) @map("notifications_enabled") + pausedAt DateTime? @map("paused_at") + snoozedUntil DateTime? @map("snoozed_until") /// v1.5 — date the medication course begins. Anchors RRULE BYDAY / /// BYMONTHDAY patterns and the rolling-interval countdown's first /// window. NULL = "active from creation" (the legacy implicit /// behaviour preserved for backwards compatibility). Required when /// `oneShot` is true. - startsOn DateTime? @map("starts_on") @db.Date + startsOn DateTime? @map("starts_on") @db.Date /// v1.5 — date the medication course ends. Reminder worker stops /// minting slots past this date. NULL = no end date (chronic). /// When `oneShot` is true, `endsOn` always equals `startsOn`. - endsOn DateTime? @map("ends_on") @db.Date + endsOn DateTime? @map("ends_on") @db.Date /// v1.5 — single-administration medication (flu shot, vaccine, /// post-op single dose, etc.). When true the medication has at /// most one MedicationIntakeEvent and `active` flips to false once @@ -1679,7 +1773,7 @@ model Medication { /// recurring schedules; the form layer enforces the constraint and /// the migration backfills `oneShot = false` for every existing /// row. - oneShot Boolean @default(false) @map("one_shot") + oneShot Boolean @default(false) @map("one_shot") /// v1.16.11 (#316) — as-needed (PRN) medication. Taken only when the /// user needs it (pain relief, rescue inhaler): no fixed schedule, /// never due, never reminded, never counted in compliance, stays @@ -1689,56 +1783,56 @@ model Medication { /// schedules array alongside `asNeeded: true`); every "never due / /// never reminded" surface follows structurally from the empty /// schedule list. Mutually exclusive with `oneShot`. - asNeeded Boolean @default(false) @map("as_needed") + asNeeded Boolean @default(false) @map("as_needed") /// v1.6.0 — route of administration. Decoupled from `treatmentClass` /// so the injection-site picker can surface for any INJECTION dose. /// A one-time injection is `oneShot = true` + `deliveryForm = /// INJECTION`. The migration backfills ORAL for every existing row. - deliveryForm MedicationDeliveryForm @default(ORAL) @map("delivery_form") + deliveryForm MedicationDeliveryForm @default(ORAL) @map("delivery_form") /// v1.8.5 — per-medication injection-site tracking opt-in. Only /// meaningful (and only offered in the UI) when `deliveryForm === /// INJECTION`. Default false: after marking a dose taken the client /// prompts for the site only when this is true, and the prompt is /// always skippable (never blocking). Deactivatable at any time. - trackInjectionSites Boolean @default(false) @map("track_injection_sites") + trackInjectionSites Boolean @default(false) @map("track_injection_sites") /// v1.8.5 — per-medication allowed / preferred injection sites. Empty /// = no per-medication restriction (every site is offered). The /// effective pickable set is this list MINUS the user-level /// `globalExcludedInjectionSites` deny-list (deny always wins). The /// intake write path validates a submitted site against the effective /// set and 422s on a disallowed value. - allowedInjectionSites InjectionSite[] @default([]) @map("allowed_injection_sites") + allowedInjectionSites InjectionSite[] @default([]) @map("allowed_injection_sites") /// v1.7.0 — iOS Live Activity opt-in for this medication's reminders. /// Default false; existing rows backfill false. The server stores + /// echoes the flag; the iOS client owns the ActivityKit lifecycle. No /// server-side behaviour hangs off it. - liveActivityEnabled Boolean @default(false) @map("live_activity_enabled") + liveActivityEnabled Boolean @default(false) @map("live_activity_enabled") /// v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. Critical alarms /// bypass the device mute switch / Focus. Default false; the server /// stores the preference only — it does not itself authorise anything /// server-side. - criticalAlarmEnabled Boolean @default(false) @map("critical_alarm_enabled") + criticalAlarmEnabled Boolean @default(false) @map("critical_alarm_enabled") /// v1.9.0 — optional ATC classification code (active-substance class, /// e.g. `A10BX10`). User/clinician-asserted; never machine-guessed on /// the export path. NULL = no code (the FHIR exporter emits a text-only /// `medicationCodeableConcept`, the pre-v1.9.0 behaviour). The WHO URI /// `http://www.whocc.no/atc` is the emitted system. Validated on write /// against `^[A-Z]\d{2}[A-Z]{2}\d{2}$`. - atcCode String? @map("atc_code") + atcCode String? @map("atc_code") /// v1.9.0 — optional RxNorm RxCUI (US; secondary coding). Same NULL /// semantics as `atcCode`. Validated on write against `^\d+$`. Emitted /// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any /// ATC code, never instead of the free-text `.text` anchor. - rxNormCode String? @map("rxnorm_code") + rxNormCode String? @map("rxnorm_code") /// v1.16.11 — low-stock notification stamp. Set when the daily /// low-stock pass dispatched a notification for the current /// threshold crossing; cleared (re-armed) when the projected runway /// rises back to / above the user's threshold (refill). NULL = armed. - lowStockNotifiedAt DateTime? @map("low_stock_notified_at") + lowStockNotifiedAt DateTime? @map("low_stock_notified_at") /// v1.16.11 — the user threshold (days) the stamp was written /// against. A changed threshold re-arms the medication even while /// the runway stays low, so the user hears about the new crossing. - lowStockNotifiedThresholdDays Int? @map("low_stock_notified_threshold_days") + lowStockNotifiedThresholdDays Int? @map("low_stock_notified_threshold_days") /// v1.17.0 — optional per-medication reorder lead time in days (how /// long between placing a refill order and the new supply arriving). /// The low-stock alert adds this lead plus one dose-interval on top of @@ -1746,9 +1840,9 @@ model Medication { /// cadence. NULL = inherit the per-user /// `notificationPrefs.medication.reorderLeadDays` default. Additive; no /// backfill. - reorderLeadDays Int? @map("reorder_lead_days") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + reorderLeadDays Int? @map("reorder_lead_days") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") schedules MedicationSchedule[] intakeEvents MedicationIntakeEvent[] @@ -1785,8 +1879,8 @@ model Medication { } model MedicationSchedule { - id String @id @default(cuid()) - medicationId String @map("medication_id") + id String @id @default(cuid()) + medicationId String @map("medication_id") /// v1.4.25 W4e — schema-convention fix. Prior to this release the DB /// columns were the camelCase identifiers `"windowStart"` / `"windowEnd"` /// because the original 0001_init migration omitted `@map`. The @@ -1794,10 +1888,10 @@ model MedicationSchedule { /// `windowEnd`); only the underlying SQL identifier is renamed to /// match the snake_case convention every other column on this model /// already follows. - windowStart String @map("window_start") // HH:mm format, e.g. "08:00" - windowEnd String @map("window_end") // HH:mm format, e.g. "10:00" - label String? // e.g. "Morning", "Evening" - dose String? // Per-schedule dose override; null = use Medication.dose + windowStart String @map("window_start") // HH:mm format, e.g. "08:00" + windowEnd String @map("window_end") // HH:mm format, e.g. "10:00" + label String? // e.g. "Morning", "Evening" + dose String? // Per-schedule dose override; null = use Medication.dose /// Legacy string encoding of the cadence ("null | 1,3,5 | i2;1,3,5"). /// v1.5 writes go to `rrule` / `rollingIntervalDays` and v1.5.1 flipped /// the readers to consult the new fields first. @@ -1811,18 +1905,18 @@ model MedicationSchedule { /// the medications list/detail pages parse it through /// `parseScheduleRecurrence`). Dropping it would break those callers, /// so it stays until those readers are migrated off the legacy pair. - daysOfWeek String? @map("days_of_week") + daysOfWeek String? @map("days_of_week") /// v1.5 — one or more `HH:mm` strings naming the times the dose is /// taken (in the user's timezone). Replaces the legacy single /// `windowStart` mental model — patients think in points-in-time, /// not in windows. Migration backfills `[windowStart]` for every /// pre-v1.5 row. - timesOfDay String[] @default([]) @map("times_of_day") + timesOfDay String[] @default([]) @map("times_of_day") /// v1.5 — reminder grace window in minutes. Replaces the implicit /// `windowEnd - windowStart` computation for late-dose /// classification. NULL falls back to the legacy math (the /// reminder worker honours `windowEnd` when this is null). - reminderGraceMinutes Int? @map("reminder_grace_minutes") + reminderGraceMinutes Int? @map("reminder_grace_minutes") /// v1.5 — RFC 5545 RRULE string for calendar-anchored cadences /// (every N weeks, monthly, quarterly, yearly). Examples: /// FREQ=DAILY @@ -1834,27 +1928,27 @@ model MedicationSchedule { /// Mutually exclusive with `rollingIntervalDays` — enforced by the /// `medication_schedules_rrule_xor_rolling` CHECK constraint added /// in the v1.5 migration. - rrule String? @map("rrule") + rrule String? @map("rrule") /// v1.5 — flexible-rolling interval in days, counted forward from /// the latest `MedicationIntakeEvent.takenAt` for the parent /// medication. NULL when the schedule is calendar-anchored. /// Mutually exclusive with `rrule`. Range 1..365 enforced at the /// Zod layer; the column itself is unconstrained at the DB so /// future extensions don't need a new migration. - rollingIntervalDays Int? @map("rolling_interval_days") + rollingIntervalDays Int? @map("rolling_interval_days") /// v1.7.0 — schedule-type discriminator. SCHEDULED = the existing rrule /// / rolling / legacy cadence. PRN = as-needed (never projected, /// reminded, or counted in compliance expected; still loggable). /// CYCLIC = N weeks on / M weeks off, gating whichever inner cadence /// the rrule / legacy fields describe. Backfills SCHEDULED. - scheduleType MedicationScheduleType @default(SCHEDULED) @map("schedule_type") + scheduleType MedicationScheduleType @default(SCHEDULED) @map("schedule_type") /// v1.7.0 — cyclic "on" weeks. Only meaningful when /// `scheduleType = CYCLIC`. The phase repeats (cyclicOnWeeks on, then /// cyclicOffWeeks off) from the medication's `startsOn ?? createdAt`. - cyclicOnWeeks Int? @map("cyclic_on_weeks") + cyclicOnWeeks Int? @map("cyclic_on_weeks") /// v1.7.0 — cyclic "off" weeks. Only meaningful when /// `scheduleType = CYCLIC`. - cyclicOffWeeks Int? @map("cyclic_off_weeks") + cyclicOffWeeks Int? @map("cyclic_off_weeks") /// v1.15.18 — per-dose on-time intake WINDOW (the configurable lever). /// A JSON array of `{ timeOfDay, start, end }` HH:mm triples, one per /// dose time the user wants an explicit range for (e.g. @@ -1864,7 +1958,7 @@ model MedicationSchedule { /// `timeOfDay` with no entry falls back to the symmetric ±1h default. /// The late tail stays cadence-derived. NULL = every slot uses the /// default derivation (unchanged behaviour). Additive + iOS-safe. - doseWindows Json? @map("dose_windows") + doseWindows Json? @map("dose_windows") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) telegramMessages TelegramReminderMessage[] @@ -1891,25 +1985,25 @@ model MedicationSchedule { /// at `max(medication.createdAt, startsOn)`; a times-only edit must /// never move `startsOn`. model MedicationScheduleRevision { - id String @id @default(cuid()) - medicationId String @map("medication_id") + id String @id @default(cuid()) + medicationId String @map("medication_id") /// Inclusive start of the era this archived state was live for. /// Chains from the previous revision's `validUntil`, or from /// `medication.createdAt` for the first revision. - validFrom DateTime @map("valid_from") @db.Timestamptz(6) + validFrom DateTime @map("valid_from") @db.Timestamptz(6) /// Exclusive end of the era — the instant the replace happened. - validUntil DateTime @map("valid_until") @db.Timestamptz(6) + validUntil DateTime @map("valid_until") @db.Timestamptz(6) /// Full snapshot of the superseded schedule rows: an array of /// `{ timesOfDay, windowStart, windowEnd, daysOfWeek, rrule, /// rollingIntervalDays, scheduleType, cyclicOnWeeks, cyclicOffWeeks, /// doseWindows, label, dose, reminderGraceMinutes }` objects — enough /// to rebuild a `CanonicalSchedule` list for era minting. - payload Json + payload Json /// v1.16.5 — provenance. 'ARCHIVED' = minted by the schedule-replace /// write path (immutable history). 'MANUAL' = user-entered pre-tracking /// era via POST /api/medications/{id}/schedule-revisions (deletable by /// the owner). - source String @default("ARCHIVED") + source String @default("ARCHIVED") /// v1.16.6 — audit trail for corrected archives. When the owner /// corrects an ARCHIVED era through PATCH /// /api/medications/{id}/schedule-revisions/{revisionId}, the route @@ -1917,8 +2011,8 @@ model MedicationScheduleRevision { /// original row stays as the audit record; every era consumer /// (splitter, list endpoint, live-boundary reads) skips rows where /// this is set. - supersededByRevisionId String? @map("superseded_by_revision_id") - createdAt DateTime @default(now()) @map("created_at") + supersededByRevisionId String? @map("superseded_by_revision_id") + createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@ -1985,12 +2079,12 @@ enum InjectionSite { } model MedicationIntakeEvent { - id String @id @default(cuid()) - userId String @map("user_id") - medicationId String @map("medication_id") - scheduledFor DateTime @map("scheduled_for") - takenAt DateTime? @map("taken_at") - skipped Boolean @default(false) + id String @id @default(cuid()) + userId String @map("user_id") + medicationId String @map("medication_id") + scheduledFor DateTime @map("scheduled_for") + takenAt DateTime? @map("taken_at") + skipped Boolean @default(false) /// v1.15.9 — distinguishes a never-acted forgotten dose from a /// deliberate user skip. The hourly auto-miss cron sets this (NOT /// `skipped`) on a pending row past its miss cutoff so the compliance @@ -1998,7 +2092,7 @@ model MedicationIntakeEvent { /// `skipped = true` stays excluded (a deliberate drug holiday). Every /// legacy row defaults to false (a user skip or a live pending row, /// neither of which is an auto-miss). - autoMissed Boolean @default(false) @map("auto_missed") + autoMissed Boolean @default(false) @map("auto_missed") /// v1.15.20 — how this row's `scheduledFor` slot binding came to be. /// `AUTO` (default) = the write path attributed the take by window-band /// membership (or the row is a skip / pending mint anchored on its slot). @@ -2010,19 +2104,19 @@ model MedicationIntakeEvent { /// re-running takenAt-band attribution — visibly marked, counted late, /// never on-time-washed — and surfaces released rows as ad-hoc; the /// nightly dedup leaves both standalone. - attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source") - source IntakeSource @default(WEB) - idempotencyKey String? @unique @map("idempotency_key") - createdAt DateTime @default(now()) @map("created_at") + attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source") + source IntakeSource @default(WEB) + idempotencyKey String? @unique @map("idempotency_key") + createdAt DateTime @default(now()) @map("created_at") /// v1.4.25 W4d — only populated for GLP-1 intake events (the picker /// is the only surface that writes this column). Existing rows stay /// NULL forever and the read paths treat NULL as "no site recorded". - injectionSite InjectionSite? @map("injection_site") + injectionSite InjectionSite? @map("injection_site") /// v1.16.4 — per-intake dose override. Free text (mirrors /// `Medication.dose`), populated only when the user edits the dose at /// log time (half tablet, titration day). NULL = the configured /// medication / schedule dose applies; read paths fall back to it. - doseTaken String? @map("dose_taken") + doseTaken String? @map("dose_taken") /// v1.16.10 — inventory consumption stamp: `[{itemId, units}]` for the /// containers this taken event decremented. NULL = never consumed /// (pending / skipped / pre-v1.16.10 rows). The stamp is the exactly- @@ -2031,22 +2125,22 @@ model MedicationIntakeEvent { /// the restore hook (taken → skipped / deleted refunds per item). The /// stamp freezes history: editing `Medication.unitsPerDose` later /// never changes what an already-taken dose consumed. - inventoryConsumption Json? @map("inventory_consumption") + inventoryConsumption Json? @map("inventory_consumption") // v1.7.0 sync — server-set mutation timestamp. The model had no // `updatedAt`; the offline-sync keyset feed orders on it, so it is // added here. `@updatedAt` makes Prisma maintain it on every write. - updatedAt DateTime @updatedAt @map("updated_at") + updatedAt DateTime @updatedAt @map("updated_at") // v1.7.0 sync — reconciliation counter. An intake is an immutable // fact, so a "correction" is a tombstone + re-insert rather than an // in-place edit; syncVersion still bumps on the soft-delete write so // the feed echoes a monotonic value. Default 0 for legacy rows. - syncVersion Int @default(0) @map("sync_version") + syncVersion Int @default(0) @map("sync_version") // v1.7.0 sync — soft-delete tombstone. The user-facing per-event // DELETE route flips this (and bumps syncVersion) rather than removing // the row, so `/api/sync/changes` surfaces the deletion as a tombstone // keyed on the server `id`. NULL = live; non-NULL = tombstoned. // Read paths (today / compliance / lists) filter `deletedAt: null`. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@ -2494,13 +2588,13 @@ model RefreshToken { // ─── Withings ────────────────────────────────────────────── model WithingsConnection { - id String @id @default(cuid()) - userId String @unique @map("user_id") - withingsUserId String @map("withings_user_id") - accessToken String @map("access_token") // encrypted at app level - refreshToken String @map("refresh_token") // encrypted at app level - tokenExpiresAt DateTime @map("token_expires_at") - lastSyncedAt DateTime? @map("last_synced_at") + id String @id @default(cuid()) + userId String @unique @map("user_id") + withingsUserId String @map("withings_user_id") + accessToken String @map("access_token") // encrypted at app level + refreshToken String @map("refresh_token") // encrypted at app level + tokenExpiresAt DateTime @map("token_expires_at") + lastSyncedAt DateTime? @map("last_synced_at") /// v1.4.25 W5d — comma-separated OAuth scope string returned by the /// Withings authorisation flow. Legacy v1.4.24- connections sit on /// `user.metrics` only; v1.4.25 starts requesting @@ -2508,7 +2602,7 @@ model WithingsConnection { /// activity / sleep endpoints unlock. Null = legacy connection that /// has never re-authed since v1.4.25 — the UI surfaces a banner /// inviting them to reconnect. - scope String? @map("scope") + scope String? @map("scope") /// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings /// sleep rows were stamped with the segment START; the reader treats /// `measuredAt` as the END, shifting every night one segment-length earlier. @@ -2516,8 +2610,8 @@ model WithingsConnection { /// and re-syncs with the corrected END stamp, then sets this so the discovery /// query drops the connection. Null = backfill not yet run. sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2568,18 +2662,18 @@ model WithingsOAuthState { // column registry extends the same way. model WhoopConnection { - id String @id @default(cuid()) - userId String @unique @map("user_id") - whoopUserId String @map("whoop_user_id") - accessToken String @map("access_token") // encrypted at app level - refreshToken String @map("refresh_token") // encrypted at app level - tokenExpiresAt DateTime @map("token_expires_at") + id String @id @default(cuid()) + userId String @unique @map("user_id") + whoopUserId String @map("whoop_user_id") + accessToken String @map("access_token") // encrypted at app level + refreshToken String @map("refresh_token") // encrypted at app level + tokenExpiresAt DateTime @map("token_expires_at") /// Space-separated OAuth scope string granted by the WHOOP authorisation /// flow (`offline read:recovery read:sleep read:workout read:cycles /// read:profile read:body_measurement`). `offline` is required to receive /// a refresh token. Null = legacy connection that predates scope tracking. - scope String? @map("scope") - lastSyncedAt DateTime? @map("last_synced_at") + scope String? @map("scope") + lastSyncedAt DateTime? @map("last_synced_at") /// v1.16.16 (iOS #17) — per-resource sync cursors. A JSON map /// `{ "recovery": "", "sleep": "", "workout": "", /// "cycle": "" }` of each resource's own last-synced instant. The @@ -2588,10 +2682,10 @@ model WhoopConnection { /// 403-ing collection never drags the cursor forward for its siblings and /// its own late-arriving records stay inside the window. Null / a missing /// key falls back to the shared `lastSyncedAt` (legacy connection). - resourceCursors Json? @map("resource_cursors") + resourceCursors Json? @map("resource_cursors") /// Set once the self-converging history backfill walks every collection to /// completion. Null = backfill still in flight (or never started). - backfillCompletedAt DateTime? @map("backfill_completed_at") + backfillCompletedAt DateTime? @map("backfill_completed_at") /// v1.17.1 — one-shot marker for the sleep-timeline backfill. Pre-fix WHOOP /// sleep rows stamped all five stage totals on the one sleep-END instant, so /// the hypnogram stacked them on the night's right edge. The boot-time @@ -2602,9 +2696,9 @@ model WhoopConnection { /// Max heart rate from the WHOOP body-measurement profile — a profile /// constant, not a time series, so it lives here rather than as a /// Measurement row. - maxHeartRate Int? @map("max_heart_rate") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + maxHeartRate Int? @map("max_heart_rate") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2622,16 +2716,16 @@ model WhoopConnection { /// the row is gone. /// * A daily cleanup cron sweeps abandoned rows whose `expiresAt` passed. model WhoopOAuthState { - nonce String @id - userId String @map("user_id") - createdAt DateTime @default(now()) @map("created_at") - expiresAt DateTime @map("expires_at") + nonce String @id + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") /// v1.12.2 — optional native custom-scheme (e.g. `dev.healthlog.app`) the /// connect route validated against the strict allowlist. Carried here so it /// survives the OAuth round-trip server-side (never in the URL/cookie); the /// callback reads it off the consumed row to drive its FINAL redirect to /// `://whoop?…` instead of the web settings URL. Null = web redirect. - returnScheme String? @map("return_scheme") + returnScheme String? @map("return_scheme") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2677,21 +2771,21 @@ model WhoopConnectTicket { /// use, so `refreshToken` is overwritten only when a token response carries a /// fresh one. model FitbitConnection { - id String @id @default(cuid()) - userId String @unique @map("user_id") - fitbitUserId String @map("fitbit_user_id") // Google/Fitbit external id - accessToken String @map("access_token") // encrypted at app level - refreshToken String @map("refresh_token") // encrypted at app level - tokenExpiresAt DateTime @map("token_expires_at") + id String @id @default(cuid()) + userId String @unique @map("user_id") + fitbitUserId String @map("fitbit_user_id") // Google/Fitbit external id + accessToken String @map("access_token") // encrypted at app level + refreshToken String @map("refresh_token") // encrypted at app level + tokenExpiresAt DateTime @map("token_expires_at") /// Space-separated OAuth scope string granted by the Google authorisation /// flow. Null = legacy connection that predates scope tracking. - scope String? @map("scope") - lastSyncedAt DateTime? @map("last_synced_at") + scope String? @map("scope") + lastSyncedAt DateTime? @map("last_synced_at") /// Set once the self-converging history backfill walks every collection to /// completion. Null = backfill still in flight (or never started). backfillCompletedAt DateTime? @map("backfill_completed_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2724,12 +2818,12 @@ model FitbitOAuthState { // ─── Mood Entries (moodLog) ─────────────────────────────────── model MoodEntry { - id String @id @default(cuid()) - userId String @map("user_id") - date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin - mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG - score Int // 1-5 - tags String? // JSON array of tag keys + id String @id @default(cuid()) + userId String @map("user_id") + date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin + mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG + score Int // 1-5 + tags String? // JSON array of tag keys /// v1.4.30 — first-class free-text note. Replaces the /// `tags: ["note:"]` workaround the iOS app shipped through /// v0.4.1.1 (per R-E H-5); the prior pattern contaminated the @@ -2737,8 +2831,17 @@ model MoodEntry { /// taxonomic labels. Nullable for the additive-only migration /// path; the bulk-backfill endpoint pulls existing `note:...` tag /// rows into this column via the migration script. - note String? @db.Text - source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO + /// + /// LEGACY plaintext — superseded by `noteEncrypted` (v1.23). New writes go to + /// the encrypted column and null this; the boot-time backfill migrates the + /// remaining rows. Column drop is a follow-up release once the backfill + /// reports zero remaining. + note String? @db.Text + /// v1.23 — free-text mood note, AES-256-GCM at rest (Bytes, matching the + /// IllnessDayLog / CycleDayLog / LabResult note convention). Registered in + /// ENCRYPTED_COLUMNS + the rotation script. + noteEncrypted Bytes? @map("note_encrypted") + source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO // v1.12.1 — stable per-source dedup id. NULL for native / MANUAL // entries (those dedup on the legacy `(userId, date, moodLoggedAt)` // key). When an importer receives a source-stable id (the moodLog @@ -2747,23 +2850,23 @@ model MoodEntry { // idempotent even when `moodLoggedAt` re-rounds / re-zones. Postgres // treats NULLs as distinct in that unique, so MANUAL rows never // collide on it. See migration 0122. - externalId String? @map("external_id") - moodLoggedAt DateTime @map("mood_logged_at") + externalId String? @map("external_id") + moodLoggedAt DateTime @map("mood_logged_at") /// v1.4.25 W7b — per-row IANA timezone that the `date` string is /// anchored to. Nullable for backward compatibility: legacy rows /// (`tz IS NULL`) were written under the Europe/Berlin assumption /// and read-paths interpret them as such. New rows always carry the /// user's `displayTimezone` at write time. - tz String? - syncedAt DateTime @default(now()) @map("synced_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + tz String? + syncedAt DateTime @default(now()) @map("synced_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") // v1.7.0 sync — LWW reconciliation counter for the offline-sync feed. // Monotonically bumped on every server-side mutation (update + // soft-delete). Mood is last-writer-wins by syncVersion: the higher // value wins. Default 0 for the additive migration so legacy rows // round-trip unchanged. - syncVersion Int @default(0) @map("sync_version") + syncVersion Int @default(0) @map("sync_version") // v1.7.0 sync — soft-delete tombstone. The user-facing DELETE route // flips this (and bumps syncVersion) rather than removing the row, so // the `/api/sync/changes` feed surfaces the deletion as a tombstone @@ -2771,7 +2874,7 @@ model MoodEntry { // NULL = live row; non-NULL = tombstoned at the recorded instant. // Every list / detail / analytics / rollup read filters // `deletedAt: null` so tombstoned rows never surface in normal reads. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -3467,23 +3570,23 @@ model IdempotencyKey { /// for whatever new gateway labels Apple ships next without a forced /// migration. model Device { - id String @id @default(cuid()) - userId String @map("user_id") - platform String // "ios" - token String @unique - bundleId String @map("bundle_id") - locale String? - appVersion String? @map("app_version") - model String? - apnsToken String? @map("apns_token") - apnsEnvironment String? @map("apns_environment") // "sandbox" | "production" + id String @id @default(cuid()) + userId String @map("user_id") + platform String // "ios" + token String @unique + bundleId String @map("bundle_id") + locale String? + appVersion String? @map("app_version") + model String? + apnsToken String? @map("apns_token") + apnsEnvironment String? @map("apns_environment") // "sandbox" | "production" /// v1.7.0 — per-device medication-delivery override. NULL = inherit the /// user-level roaming default /// (`User.notificationPrefs.medication.deliveryDefault`). "server" /// forces server APNs for this device; "client" forces local. Stored + /// echoed in v1.7.0; cron suppression stays user-level (APNs fans out to /// all the user's devices and iOS dedupes locally). - medicationDelivery String? @map("medication_delivery") + medicationDelivery String? @map("medication_delivery") /// v1.17.1 (#22) — APNs push token for the iOS Live Activity update/end /// channel. ActivityKit issues a per-Activity push token distinct from the /// device APNs token; the server stores the most recent one so the worker @@ -3491,10 +3594,10 @@ model Device { /// the device has no active Live Activity. The iOS client owns the /// ActivityKit lifecycle and registers/clears this token; no server-side /// behaviour hangs off it beyond addressing the update push. - liveActivityPushToken String? @map("live_activity_push_token") - lastSeen DateTime @default(now()) @map("last_seen") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + liveActivityPushToken String? @map("live_activity_push_token") + lastSeen DateTime @default(now()) @map("last_seen") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -3630,7 +3733,7 @@ model LabResult { /// the Biomarker, ending the per-row free-text divergence ("LDL" vs "ldl" /// vs "LDL-C", `mg/dL` vs `mg/dl`). The legacy `analyte` / `unit` / /// `reference*` columns stay as historical truth for one release. - biomarkerId String? @map("biomarker_id") + biomarkerId String? @map("biomarker_id") /// Provenance: "MANUAL" today; import paths set their own source string. source String @default("MANUAL") /// Optional free-text note, AES-256-GCM encrypted at rest via @@ -3662,29 +3765,29 @@ model LabResult { /// recomputes). Unique per user + name so there is never two "LDL" /// definitions. model Biomarker { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// Canonical display name, e.g. "LDL Cholesterol". - name String + name String /// Canonical unit for every reading against this marker, e.g. "mg/dL". - unit String + unit String /// Optional reference-range bounds. The lab/sex/age-dependent "normal" /// window; the server's range verdict is computed against these. - lowerBound Float? @map("lower_bound") - upperBound Float? @map("upper_bound") + lowerBound Float? @map("lower_bound") + upperBound Float? @map("upper_bound") /// Optional per-marker context ("what this means"), AES-256-GCM encrypted /// at rest via `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per /// convention). - contextEncrypted Bytes? @map("context_encrypted") + contextEncrypted Bytes? @map("context_encrypted") /// Optional panel grouping moved off the per-entry row, e.g. /// "Großes Blutbild". NULL = standalone marker. - panel String? + panel String? /// A marker the user no longer needs: dropped from the active catalog list /// and from the lab-entry pickers, but never deleted — its readings and /// canonical unit/range definition survive. - hidden Boolean @default(false) @map("hidden") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + hidden Boolean @default(false) @map("hidden") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) results LabResult[] @@ -4027,26 +4130,26 @@ model CoachReminder { // must never land in the table as plaintext. A cleared profile keeps the // row with a NULL payload so `updatedAt` documents the deletion instant. model UserHealthProfile { - id String @id @default(cuid()) - userId String @unique @map("user_id") + id String @id @default(cuid()) + userId String @unique @map("user_id") // Free text, hard-capped at 4 000 chars by the route's Zod schema // BEFORE encryption. NULL = cleared / never written. - aboutMeEncrypted Bytes? @map("about_me_encrypted") + aboutMeEncrypted Bytes? @map("about_me_encrypted") // v1.16.0 — structured self-context companions to the free text. // Short answers (each capped at 500 chars BEFORE encryption). // Age/gender deliberately do NOT live here — they come from the User // profile and are merged into the prompt block at assembly time so // the data is never duplicated. - conditionsEncrypted Bytes? @map("conditions_encrypted") - allergiesEncrypted Bytes? @map("allergies_encrypted") - coachFocusEncrypted Bytes? @map("coach_focus_encrypted") + conditionsEncrypted Bytes? @map("conditions_encrypted") + allergiesEncrypted Bytes? @map("allergies_encrypted") + coachFocusEncrypted Bytes? @map("coach_focus_encrypted") // v1.16.0 — up to 3 clarifying questions the server derives after a // save (AI-generated when a provider + budget allow, deterministic // hints otherwise). Encrypted JSON string[]; the Coach composer // renders them as tappable suggestion chips. NULL = none pending. - pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -4185,10 +4288,10 @@ model PushAttempt { // never-expiring share. Deleting the user cascades every share away so // the GDPR-erasure path stays clean. model ClinicianShareLink { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// HMAC-SHA256 of the raw `hls_` token (same scheme as `ApiToken.tokenHash`). - tokenHash String @unique @map("token_hash") + tokenHash String @unique @map("token_hash") /// v1.18.7 — HMAC-SHA256 of the passphrase second factor (same key as /// `tokenHash`). NULL for legacy links minted before the passphrase gate; /// those keep the possession-only contract (the public view renders without @@ -4197,21 +4300,21 @@ model ClinicianShareLink { /// as this hash — there is no recovery path. passphraseHash String? @map("passphrase_hash") /// Human label the owner sets (e.g. a clinic/specialty note). Plaintext, bounded. - label String + label String // ── Frozen scope (write-once at creation; never widened) ── /// Reporting-window start (absolute, not "last N days"). Frozen so a /// rolling share can never reach data older than the owner chose. - rangeStart DateTime @map("range_start") + rangeStart DateTime @map("range_start") /// Window end. Null = rolling ("up to now"); set = frozen snapshot window. - rangeEnd DateTime? @map("range_end") + rangeEnd DateTime? @map("range_end") /// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON. - sectionsJson Json @map("sections_json") + sectionsJson Json @map("sections_json") /// FHIR resource types this link may serve (a subset of the catalogue), /// e.g. `["Observation","MedicationStatement"]`. - resourceTypes String[] @map("resource_types") + resourceTypes String[] @map("resource_types") /// Whether the FHIR API is reachable via this link at all (vs the view only). - allowFhirApi Boolean @default(false) @map("allow_fhir_api") + allowFhirApi Boolean @default(false) @map("allow_fhir_api") /// REQUIRED — no never-expiring share (capped at `SHARE_LINK_MAX_DAYS`). expiresAt DateTime @map("expires_at") @@ -4508,11 +4611,11 @@ model CycleProfile { /// When true, every cycle push body is generic ("HealthLog reminder") /// so no cycle-event naming reaches the lock screen. - discreetNotifications Boolean @default(false) @map("discreet_notifications") + discreetNotifications Boolean @default(false) @map("discreet_notifications") /// Default-ON for this category (post-Dobbs threat model): drives /// whether the intent-revealing day-log fields encrypt at the /// application layer in addition to the always-encrypted notes. - sensitiveCategoryEncryption Boolean @default(true) @map("sensitive_category_encryption") + sensitiveCategoryEncryption Boolean @default(true) @map("sensitive_category_encryption") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -4541,14 +4644,14 @@ model MenstrualCycle { /// Observed cycle length in days (start→next start). NULL until the /// next cycle anchors it. - lengthDays Int? @map("length_days") + lengthDays Int? @map("length_days") /// YYYY-MM-DD — confirmed or estimated ovulation day. - ovulationDate String? @map("ovulation_date") + ovulationDate String? @map("ovulation_date") /// True once ovulation is confirmed (symptothermal / temperature-trend), /// as opposed to a calendar estimate. - ovulationConfirmed Boolean @default(false) @map("ovulation_confirmed") + ovulationConfirmed Boolean @default(false) @map("ovulation_confirmed") /// Forward-minted forecast row vs an observed cycle. - isPredicted Boolean @default(false) @map("is_predicted") + isPredicted Boolean @default(false) @map("is_predicted") /// Per-row IANA timezone the date strings are anchored to (NULL = /// legacy Europe/Berlin), mirroring MoodEntry.tz. @@ -4575,8 +4678,8 @@ model MenstrualCycle { /// canonical-day key plus the NULL-distinct external-dedup key for /// HealthKit re-ingest. Soft-deleted + syncVersion-bumped. model CycleDayLog { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// YYYY-MM-DD — anchored to `tz` when non-null, else legacy /// Europe/Berlin (the MoodEntry.date convention). date String @@ -4585,25 +4688,25 @@ model CycleDayLog { cycleId String? @map("cycle_id") /// Menstrual-flow intensity (NULL = not logged this day). - flow FlowLevel? + flow FlowLevel? /// Spotting / bleeding outside the period window. - intermenstrualBleeding Boolean @default(false) @map("intermenstrual_bleeding") + intermenstrualBleeding Boolean @default(false) @map("intermenstrual_bleeding") /// Basal body temperature in Celsius. Queryable-plaintext — feeds the /// symptothermal confirmation. BBT also lands in Measurement /// (BODY_TEMPERATURE) for charting; this column is the cycle-local copy. - basalBodyTempC Float? @map("basal_body_temp_c") + basalBodyTempC Float? @map("basal_body_temp_c") /// Whether this day's BBT is a disturbed/unreliable reading (fever, /// illness, alcohol, a late or short-sleep measurement) the user marked /// excluded. When true the symptothermal engine drops the reading from the /// cover-line baseline AND the shift evaluation, so a spike neither /// fabricates nor masks a temperature rise (Sensiplan: the cover line is /// drawn over the last six *unbracketed* values). - temperatureExcluded Boolean @default(false) @map("temperature_excluded") + temperatureExcluded Boolean @default(false) @map("temperature_excluded") /// Ovulation predictor-kit (OPK) reading. - ovulationTest OvulationTest? @map("ovulation_test") + ovulationTest OvulationTest? @map("ovulation_test") /// Cervical-mucus quality. - cervicalMucus CervicalMucus? @map("cervical_mucus") + cervicalMucus CervicalMucus? @map("cervical_mucus") /// Cervix observation — the three standard Sensiplan cervix signs, used /// as the symptothermal secondary indicator when @@ -4618,8 +4721,8 @@ model CycleDayLog { /// Intent-revealing fields. When CycleProfile.sensitiveCategoryEncryption /// is ON these are encrypted at the application layer (the dispatch /// envelope), default-ON for this category. - sexualActivity Boolean @default(false) @map("sexual_activity") - protectedSex Boolean? @map("protected_sex") + sexualActivity Boolean @default(false) @map("sexual_activity") + protectedSex Boolean? @map("protected_sex") pregnancyTest HomeTestResult? @map("pregnancy_test") progesteroneTest HomeTestResult? @map("progesterone_test") contraceptive ContraceptiveKind? @map("contraceptive") @@ -4759,39 +4862,39 @@ model CycleSymptomLink { /// hanging off a parent condition (`parentConditionId`). CHRONIC_ONGOING /// has no "recovered" date and is excluded from the recovery-gap math. model IllnessEpisode { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// User-facing label for the episode, e.g. "Erkältung", "Heuschnupfen". - label String + label String /// Broad condition class. Widened beyond an infection log to cover the /// condition journal's scope. - type IllnessType + type IllnessType /// Where this episode sits in its lifecycle. FLARE rows reference a /// parent condition via `parentConditionId`. - lifecycle IllnessLifecycle @default(ACUTE) + lifecycle IllnessLifecycle @default(ACUTE) /// When the episode began (UTC instant; wall-clock anchoring is the /// reader's concern per the schema's UTC-only convention). - onsetAt DateTime @map("onset_at") + onsetAt DateTime @map("onset_at") /// When the episode resolved. NULL = still ongoing. CHRONIC_ONGOING /// episodes stay NULL by design. - resolvedAt DateTime? @map("resolved_at") + resolvedAt DateTime? @map("resolved_at") /// Self-FK for flares: a FLARE/RECURRING bout hangs off the parent /// condition episode so the history threads under one condition. - parentConditionId String? @map("parent_condition_id") + parentConditionId String? @map("parent_condition_id") /// Optional free-text note, AES-256-GCM encrypted at rest via /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per /// convention). - noteEncrypted Bytes? @map("note_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Parent condition for a flare/recurrence (self-relation). - parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) - flares IllnessEpisode[] @relation("IllnessFlares") - dayLogs IllnessDayLog[] + parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) + flares IllnessEpisode[] @relation("IllnessFlares") + dayLogs IllnessDayLog[] @@index([userId, deletedAt]) @@index([userId, onsetAt]) @@ -4803,30 +4906,30 @@ model IllnessEpisode { /// day's free-text note (encrypted), a coarse functional-impact slider, /// an optional fever reading, plus the symptom links. Soft-deleted. model IllnessDayLog { - id String @id @default(cuid()) - userId String @map("user_id") - episodeId String @map("episode_id") + id String @id @default(cuid()) + userId String @map("user_id") + episodeId String @map("episode_id") /// YYYY-MM-DD — anchored to `tz` when non-null, else legacy /// Europe/Berlin (the CycleDayLog / MoodEntry date convention). - date String + date String /// Coarse self-rated functional impact for the day (0 = no impact / /// fully functional … 3 = bedbound). NULL = not logged. A slider, not a /// symptom — kept on the day row like CycleDayLog's per-day signs. - functionalImpact Int? @map("functional_impact") + functionalImpact Int? @map("functional_impact") /// Optional body temperature for the day, in Celsius. Queryable /// plaintext (the CycleDayLog.basalBodyTempC precedent): the fever /// signal feeds the retrospective curve. NULL = not measured. - feverC Float? @map("fever_c") + feverC Float? @map("fever_c") /// AES-256-GCM ciphertext envelope of the day's free-text note (always /// encrypted — the `*Encrypted` Bytes convention). - noteEncrypted Bytes? @map("note_encrypted") + noteEncrypted Bytes? @map("note_encrypted") /// Optional timezone the `date` is anchored to (the CycleDayLog /// precedent). - tz String? - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + tz String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) @@ -4892,9 +4995,9 @@ model CyclePrediction { /// YYYY-MM-DD — predicted next period start (point estimate) plus the /// ranged Low / High band (rendered as a band, never a single date). - nextPeriodStart String @map("next_period_start") - nextPeriodStartLow String @map("next_period_start_low") - nextPeriodStartHigh String @map("next_period_start_high") + nextPeriodStart String @map("next_period_start") + nextPeriodStartLow String @map("next_period_start_low") + nextPeriodStartHigh String @map("next_period_start_high") /// YYYY-MM-DD — fertile-window bounds (goal-gated at the read surface). fertileWindowStart String? @map("fertile_window_start") diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 41e8ee4af..437ef7362 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -193,6 +193,8 @@ async function main() { "aiOpenaiKeyEncrypted", "aiOcrKeyEncrypted", "insuranceNumberEncrypted", + // v1.23 — TOTP shared secret (second factor). + "totpSecretEncrypted", ]; for (const field of userFields) { results.push(await rotateStringColumn("User", field, prisma.user)); @@ -405,6 +407,22 @@ async function main() { ), ); + // ───── v1.23 free-text health notes (Bytes columns) ───── + // "noteEncrypted" (MoodEntry) + "notesEncrypted" (Measurement). Same shared- + // codec shape as the clinical-spine note columns. NULL on rows whose note is + // still in the legacy plaintext column (pre-backfill) — `rotateBytesColumn` + // skips those. + results.push( + await rotateBytesColumn("MoodEntry", "noteEncrypted", prisma.moodEntry), + ); + results.push( + await rotateBytesColumn( + "Measurement", + "notesEncrypted", + prisma.measurement, + ), + ); + console.log("\n=== Rotation summary ==="); let totalRotated = 0; let totalErrors = 0; diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 5299cc133..3b6d6cfb1 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -36,6 +36,7 @@ import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { decrypt } from "@/lib/crypto"; +import { encryptNote } from "@/lib/crypto/note-cipher"; import { defaultUserIdResolver, withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; import { @@ -330,7 +331,10 @@ const handler = apiHandler( unit: m.unit, source: (m.source ?? "MANUAL") as never, measuredAt: new Date(m.measuredAt), - notes: m.notes ?? null, + // v1.23 — re-encrypt the note from the (decrypted) backup payload + // into the at-rest column; legacy plaintext column nulled. + notes: null, + notesEncrypted: encryptNote(m.notes ?? null), })), // Backups can replay the same data — the unique // (userId, type, measuredAt, source) constraint catches diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index 286e84282..16c8a85b6 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiError, getClientIp } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; +import { readNote } from "@/lib/crypto/note-cipher"; import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; import { buildCycleBackupSection } from "@/lib/cycle/backup"; import { NextRequest, NextResponse } from "next/server"; @@ -94,7 +95,9 @@ export const GET = apiHandler(async (request: NextRequest) => { unit: m.unit, measuredAt: m.measuredAt.toISOString(), source: m.source, - notes: m.notes, + // v1.23 — decrypt the note for the human-readable backup; an admin + // restore re-encrypts it on re-insert. + notes: readNote(m.notesEncrypted, m.notes), })), medications: medications.map((m) => ({ name: m.name, diff --git a/src/app/api/export/measurements/route.ts b/src/app/api/export/measurements/route.ts index f870ef7cf..3a5592596 100644 --- a/src/app/api/export/measurements/route.ts +++ b/src/app/api/export/measurements/route.ts @@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, getClientIp } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { toCSV, formatMeasurementsForExport } from "@/lib/export"; +import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { resolveGlucoseUnit } from "@/lib/glucose"; @@ -65,12 +66,16 @@ export const GET = apiHandler(async (request: NextRequest) => { const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null); const csv = toCSV( - formatMeasurementsForExport(measurements, userTz, { - granularity, - sleepTz: userTz, - sourcePriorityJson, - glucoseUnit, - }), + formatMeasurementsForExport( + measurements.map(shapeMeasurementNotes), + userTz, + { + granularity, + sleepTz: userTz, + sourcePriorityJson, + glucoseUnit, + }, + ), ); await auditLog("user.export.measurements", { diff --git a/src/app/api/export/mood/route.ts b/src/app/api/export/mood/route.ts index 1f4343944..6feb26b35 100644 --- a/src/app/api/export/mood/route.ts +++ b/src/app/api/export/mood/route.ts @@ -19,6 +19,7 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, getClientIp } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { toCSV, formatMoodEntriesForExport } from "@/lib/export"; +import { shapeMoodNote } from "@/lib/crypto/note-cipher"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { NextRequest, NextResponse } from "next/server"; @@ -62,7 +63,9 @@ export const GET = apiHandler(async (request: NextRequest) => { resolveUserTimezone(user.id), ]); - const csv = toCSV(formatMoodEntriesForExport(entries, userTz)); + const csv = toCSV( + formatMoodEntriesForExport(entries.map(shapeMoodNote), userTz), + ); await auditLog("user.export.mood", { userId: user.id, diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index c53865254..41534be3b 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -11,6 +11,7 @@ import { formatIntakeEventsForExport, formatMoodEntriesForExport, } from "@/lib/export"; +import { shapeMeasurementNotes, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { NextRequest, NextResponse } from "next/server"; @@ -70,7 +71,8 @@ export const GET = apiHandler(async (request: NextRequest) => { // Thread the user's source-priority so a multi-source night dedups to // the same canonical source the UI shows. const measurementRecords = formatMeasurementsForExport( - measurements, + // v1.23 — decrypt notes before formatting; strip the ciphertext column. + measurements.map(shapeMeasurementNotes), userTz, { sleepTz: userTz, sourcePriorityJson }, ); @@ -110,10 +112,11 @@ export const GET = apiHandler(async (request: NextRequest) => { where: { userId, deletedAt: null }, orderBy: { moodLoggedAt: "desc" }, }); + const decryptedMood = moodEntries.map(shapeMoodNote); data.moodEntries = format === "csv" - ? toCSV(formatMoodEntriesForExport(moodEntries, userTz)) - : formatMoodEntriesForExport(moodEntries, userTz); + ? toCSV(formatMoodEntriesForExport(decryptedMood, userTz)) + : formatMoodEntriesForExport(decryptedMood, userTz); } await auditLog("export.download", { diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index 2acd36808..b663f67c3 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -18,6 +18,15 @@ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), })); +// v1.23 — deterministic note-cipher so the route test stays isolated from the +// encryption-key env. `enc:` stands in for the AES-256-GCM ciphertext. +vi.mock("@/lib/crypto/note-cipher", () => ({ + encryptNote: (s: string | null | undefined) => + s === null || s === undefined || s.length === 0 + ? null + : new Uint8Array(Buffer.from(`enc:${s}`, "utf8")), +})); + vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index a645495b0..ae6a53de9 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { encryptNote } from "@/lib/crypto/note-cipher"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; @@ -124,7 +125,8 @@ export const POST = apiHandler(async (request: NextRequest) => { value: m.value, unit: m.unit, measuredAt: m.measuredAt, - notes: m.notes ?? null, + notes: null, + notesEncrypted: encryptNote(m.notes ?? null), glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null, }, @@ -136,7 +138,8 @@ export const POST = apiHandler(async (request: NextRequest) => { source: "IMPORT", externalId: m.externalId, measuredAt: m.measuredAt, - notes: m.notes ?? null, + notes: null, + notesEncrypted: encryptNote(m.notes ?? null), glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null, }, @@ -160,7 +163,8 @@ export const POST = apiHandler(async (request: NextRequest) => { unit: m.unit, source: "IMPORT", measuredAt: m.measuredAt, - notes: m.notes ?? null, + notes: null, + notesEncrypted: encryptNote(m.notes ?? null), glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null, }, diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 93b0152d4..d63df7dd2 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -18,6 +18,7 @@ import { glucoseContextEnum, } from "@/lib/validations/measurement"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; +import { encryptNote } from "@/lib/crypto/note-cipher"; import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups"; import { collapseToTypeDayKeys, @@ -144,7 +145,8 @@ export const POST = apiHandler(async (request: NextRequest) => { value: m.value, unit: m.unit, measuredAt, - notes: m.notes || null, + notes: null, + notesEncrypted: encryptNote(m.notes || null), glucoseContext: m.glucoseContext ?? null, }, create: { @@ -155,7 +157,8 @@ export const POST = apiHandler(async (request: NextRequest) => { source: "IMPORT", externalId: m.externalId, measuredAt, - notes: m.notes || null, + notes: null, + notesEncrypted: encryptNote(m.notes || null), glucoseContext: m.glucoseContext ?? null, }, }); @@ -168,7 +171,8 @@ export const POST = apiHandler(async (request: NextRequest) => { unit: m.unit, source: "IMPORT", measuredAt, - notes: m.notes || null, + notes: null, + notesEncrypted: encryptNote(m.notes || null), glucoseContext: m.glucoseContext ?? null, }, }); diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 83619abd0..f25ab7710 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -11,6 +11,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { updateMeasurementSchema } from "@/lib/validations/measurement"; +import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; @@ -110,7 +111,12 @@ export const PUT = apiHandler( data: { ...(data.value !== undefined && { value: data.value }), ...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }), - ...(data.notes !== undefined && { notes: data.notes }), + // v1.23 — write the note to the encrypted column; null the legacy + // plaintext column. An explicit `null` clears the note. + ...(data.notes !== undefined && { + notes: null, + notesEncrypted: encryptNote(data.notes), + }), }, }); } catch (err) { @@ -188,7 +194,7 @@ export const PUT = apiHandler( console.warn("[measurements] status-insight invalidate failed", err); }); - return apiSuccess(measurement); + return apiSuccess(shapeMeasurementNotes(measurement)); }, ); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 0aac4a53c..989c5fba4 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -27,6 +27,7 @@ import { localDayWindow, } from "@/lib/measurements/drain-per-sample-cumulative"; import { withIdempotency } from "@/lib/idempotency"; +import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; @@ -491,7 +492,9 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "measurement.list" }, meta: { total, type } }); return apiSuccess({ - measurements, + // v1.23 — decrypt `notesEncrypted` onto `notes` and strip the ciphertext + // before it leaves the server (iOS MeasurementWireDTO contract). + measurements: measurements.map(shapeMeasurementNotes), meta: { total, limit, offset }, }); }); @@ -738,7 +741,10 @@ async function postMeasurement(request: NextRequest) { unit: getUnitForType(m.type), source: (m.source ?? "MANUAL") as MeasurementSource, measuredAt: m.measuredAt, - notes: m.notes ?? null, + // v1.23 — encrypt the note at rest; the legacy plaintext column is + // written null for new rows. + notes: null, + notesEncrypted: encryptNote(m.notes ?? null), glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null, // v1.4.25 W10 reconcile (code-review M4): mirror the @@ -750,6 +756,8 @@ async function postMeasurement(request: NextRequest) { ), ); + const shapedResults = results.map(shapeMeasurementNotes); + await auditLog("measurement.create.batch", { userId: user.id, ipAddress: getClientIp(request), @@ -823,7 +831,7 @@ async function postMeasurement(request: NextRequest) { console.warn("[measurements] rollup recompute failed", err); } - return apiSuccess(results, 201); + return apiSuccess(shapedResults, 201); } // Single mode (existing behavior) @@ -878,7 +886,9 @@ async function postMeasurement(request: NextRequest) { unit: getUnitForType(type), source: (source ?? "MANUAL") as MeasurementSource, measuredAt, - notes: notes ?? null, + // v1.23 — encrypt the note at rest; legacy plaintext column nulled. + notes: null, + notesEncrypted: encryptNote(notes ?? null), glucoseContext: (glucoseContext as GlucoseContext | undefined) ?? null, // v1.4.25 W10 reconcile (code-review M4): the deviceType column // already accepts client metadata from the batch route. Mirror @@ -963,5 +973,5 @@ async function postMeasurement(request: NextRequest) { console.warn("[measurements] status-insight invalidate failed", err); }); - return apiSuccess(measurement, 201); + return apiSuccess(shapeMeasurementNotes(measurement), 201); } diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 0bb45c30f..95813f5db 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -16,6 +16,7 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { replaceTagLinks } from "@/lib/mood/tag-links"; @@ -53,7 +54,7 @@ export const GET = apiHandler( meta: { moodEntryId: id }, }); - return apiSuccess({ ...entry, tags: parseTags(entry.tags) }); + return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) }); }, ); @@ -128,7 +129,10 @@ export const PUT = apiHandler( updateData.tags = data.tags ? JSON.stringify(data.tags) : null; } if (data.note !== undefined) { - updateData.note = data.note; + // v1.23 — write to the encrypted column; null the legacy plaintext. An + // explicit `null` clears the note. + updateData.note = null; + updateData.noteEncrypted = encryptNote(data.note); } // v1.7.0 sync — mood is last-writer-wins by syncVersion; bump it on diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index fddbfa944..23779e338 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -44,6 +44,7 @@ import { moodSourceEnum, } from "@/lib/validations/moodlog"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; @@ -238,7 +239,8 @@ async function postBulk(request: NextRequest): Promise { mood: entry.mood, score, tags: entry.tags ? JSON.stringify(entry.tags) : null, - note: entry.note ?? null, + note: null, + noteEncrypted: encryptNote(entry.note ?? null), source: resolvedSource, externalId: entry.externalId ?? null, moodLoggedAt: entry.moodLoggedAt, @@ -253,7 +255,8 @@ async function postBulk(request: NextRequest): Promise { mood: entry.mood, score, tags: entry.tags ? JSON.stringify(entry.tags) : null, - note: entry.note ?? null, + note: null, + noteEncrypted: encryptNote(entry.note ?? null), ...(entry.externalId ? { date, moodLoggedAt: entry.moodLoggedAt } : {}), diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 655c062c3..c40cf3f67 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -18,6 +18,7 @@ import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; +import { encryptNote, readNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -119,7 +120,8 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({ - ...e, + // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. + ...shapeMoodNote(e), tags: parseTags(e.tags), // v1.8.5 — flat list of binary structured-tag keys attached to the // entry (rated factors are surfaced separately below). @@ -234,7 +236,8 @@ async function postMoodEntry(request: NextRequest) { mood, score, tags: tags ? JSON.stringify(tags) : null, - note: note ?? null, + note: null, + noteEncrypted: encryptNote(note ?? null), source: resolvedSource, externalId, moodLoggedAt, @@ -245,7 +248,8 @@ async function postMoodEntry(request: NextRequest) { mood, score, tags: tags ? JSON.stringify(tags) : null, - note: note ?? null, + note: null, + noteEncrypted: encryptNote(note ?? null), moodLoggedAt, }, }) @@ -257,7 +261,8 @@ async function postMoodEntry(request: NextRequest) { mood, score, tags: tags ? JSON.stringify(tags) : null, - note: note ?? null, + note: null, + noteEncrypted: encryptNote(note ?? null), source: resolvedSource, moodLoggedAt, }, @@ -347,7 +352,7 @@ async function postMoodEntry(request: NextRequest) { date: entry.date, moodLoggedAt: entry.moodLoggedAt, mood: entry.mood, - note: entry.note ?? null, + note: readNote(entry.noteEncrypted, entry.note), tags: entry.tags, source: entry.source, }, @@ -362,7 +367,8 @@ async function postMoodEntry(request: NextRequest) { return apiSuccess( { - ...entry, + // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. + ...shapeMoodNote(entry), tags: parseTags(entry.tags), // v1.8.5 — surface the persisted structured-tag keys so a client // hydrating from the create response renders the tag set without a diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index d6a68886a..b790d4350 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -50,6 +50,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; +import { readNote } from "@/lib/crypto/note-cipher"; import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; import { decodeCursor, @@ -249,6 +250,7 @@ export const GET = apiHandler(async (request: NextRequest) => { measuredAt: true, source: true, notes: true, + notesEncrypted: true, syncVersion: true, deletedAt: true, updatedAt: true, @@ -265,6 +267,7 @@ export const GET = apiHandler(async (request: NextRequest) => { score: true, tags: true, note: true, + noteEncrypted: true, moodLoggedAt: true, source: true, syncVersion: true, @@ -336,7 +339,7 @@ export const GET = apiHandler(async (request: NextRequest) => { unit: row.unit, measuredAt: row.measuredAt.toISOString(), source: row.source, - notes: row.notes, + notes: readNote(row.notesEncrypted, row.notes), syncVersion: row.syncVersion, updatedAt: row.updatedAt.toISOString(), }); @@ -365,7 +368,7 @@ export const GET = apiHandler(async (request: NextRequest) => { mood: row.mood, score: row.score, tags: row.tags, - note: row.note, + note: readNote(row.noteEncrypted, row.note), moodLoggedAt: row.moodLoggedAt.toISOString(), source: row.source, syncVersion: row.syncVersion, diff --git a/src/lib/crypto/__tests__/note-cipher.test.ts b/src/lib/crypto/__tests__/note-cipher.test.ts new file mode 100644 index 000000000..8b5873ddb --- /dev/null +++ b/src/lib/crypto/__tests__/note-cipher.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + encryptNote, + readNote, + shapeMeasurementNotes, + shapeMoodNote, +} from "../note-cipher"; + +// A 32-byte key as 64 hex chars (the legacy single-key path). +const KEY_A = "a".repeat(64); +const KEY_B = "b".repeat(64); + +beforeEach(() => { + // Isolate from any ambient ENCRYPTION_KEYS map; drive the legacy single key. + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", KEY_A); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("encryptNote / readNote", () => { + it("round-trips a note through encrypt -> decrypt", () => { + const cipher = encryptNote("felt dizzy after the second dose"); + expect(cipher).toBeInstanceOf(Uint8Array); + expect(readNote(cipher, null)).toBe("felt dizzy after the second dose"); + }); + + it("preserves Unicode (umlauts / emoji) verbatim", () => { + const cipher = encryptNote("Schwindel nach 2. Dosis 🤕 — Nürnberg"); + expect(readNote(cipher, null)).toBe( + "Schwindel nach 2. Dosis 🤕 — Nürnberg", + ); + }); + + it("stores nothing for an absent / empty note", () => { + expect(encryptNote(null)).toBeNull(); + expect(encryptNote(undefined)).toBeNull(); + expect(encryptNote("")).toBeNull(); + }); + + it("falls back to the legacy plaintext column ONLY when there is no ciphertext", () => { + // A not-yet-backfilled row: plaintext present, ciphertext null. + expect(readNote(null, "legacy plaintext note")).toBe( + "legacy plaintext note", + ); + expect(readNote(new Uint8Array(0), "legacy plaintext note")).toBe( + "legacy plaintext note", + ); + expect(readNote(null, null)).toBeNull(); + }); + + it("prefers the ciphertext over a stale plaintext value", () => { + const cipher = encryptNote("the real note"); + // Even if a stale plaintext lingers, the ciphertext wins. + expect(readNote(cipher, "stale plaintext")).toBe("the real note"); + }); + + it("is fail-closed: a ciphertext that cannot be decrypted throws (never leaks the fallback)", () => { + const cipher = encryptNote("secret note"); + // Rotate the key out from under the ciphertext: same key id, wrong bytes. + vi.stubEnv("ENCRYPTION_KEY", KEY_B); + expect(() => + readNote(cipher, "fallback that must NOT be returned"), + ).toThrow(); + }); +}); + +describe("shapeMeasurementNotes / shapeMoodNote", () => { + it("decrypts onto `notes` and strips the ciphertext column", () => { + const notesEncrypted = encryptNote("after lunch"); + const row = { + id: "m1", + value: 5.4, + notes: null, + notesEncrypted, + }; + const shaped = shapeMeasurementNotes(row); + expect(shaped.notes).toBe("after lunch"); + expect("notesEncrypted" in shaped).toBe(false); + expect(shaped.id).toBe("m1"); + expect(shaped.value).toBe(5.4); + }); + + it("decrypts onto `note` and strips the ciphertext column", () => { + const noteEncrypted = encryptNote("rough night"); + const row = { id: "x1", mood: "OKAY", note: null, noteEncrypted }; + const shaped = shapeMoodNote(row); + expect(shaped.note).toBe("rough night"); + expect("noteEncrypted" in shaped).toBe(false); + expect(shaped.mood).toBe("OKAY"); + }); + + it("surfaces the legacy plaintext for a not-yet-backfilled measurement row", () => { + const shaped = shapeMeasurementNotes({ + id: "m2", + notes: "legacy plaintext", + notesEncrypted: null, + }); + expect(shaped.notes).toBe("legacy plaintext"); + }); +}); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index d408bdd7d..6d9b67a97 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -71,6 +71,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // v1.22 (#90) — dedicated document-scan (Lab-OCR) provider key. { model: "User", field: "aiOcrKeyEncrypted", kind: "string" }, { model: "User", field: "insuranceNumberEncrypted", kind: "string" }, + // v1.23 — TOTP shared secret (second factor). String ciphertext like the + // other User credential columns. + { model: "User", field: "totpSecretEncrypted", kind: "string" }, // ───── OAuth token tables (`*_token`-convention columns) ───── { model: "WithingsConnection", field: "accessToken", kind: "string" }, @@ -141,6 +144,12 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // ───── v1.19.0 ECG waveform (Bytes column) ───── { model: "EcgRecording", field: "waveformEncrypted", kind: "bytes" }, + + // ───── v1.23 free-text health notes (Bytes columns) ───── + // Free-text mood diary + per-measurement notes. Same class as the + // IllnessDayLog / LabResult note columns above; Bytes via the shared codec. + { model: "MoodEntry", field: "noteEncrypted", kind: "bytes" }, + { model: "Measurement", field: "notesEncrypted", kind: "bytes" }, ] as const; /** Stable `Model.field` key for a registry entry. */ diff --git a/src/lib/crypto/note-cipher.ts b/src/lib/crypto/note-cipher.ts new file mode 100644 index 000000000..f768e9a68 --- /dev/null +++ b/src/lib/crypto/note-cipher.ts @@ -0,0 +1,75 @@ +/** + * Free-text health-note ↔ encrypted-column helpers (v1.23). + * + * `MoodEntry.note` and `Measurement.notes` are migrating from plaintext to the + * AES-256-GCM `Bytes` columns `noteEncrypted` / `notesEncrypted`. These helpers + * are the single read/write boundary every call site goes through so the + * encrypt-on-write + prefer-ciphertext-fallback-plaintext contract stays + * consistent. + * + * Storage shape is the shared Bytes codec (`@/lib/ai/coach/bytes-codec`) used + * by every other free-text encrypted-note column (IllnessDayLog, LabResult, + * Coach memory) — the `encrypt()` ciphertext string stored UTF-8 as `bytea`. + * + * FAIL-CLOSED: `readNote` decrypts a present ciphertext and throws on a bad key + * id / malformed payload rather than silently returning plaintext — the same + * posture as the rest of the crypto layer. The plaintext fallback only fires + * when there is NO ciphertext (a legacy row not yet covered by the backfill). + */ +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; + +/** + * Encrypt a free-text note for the `Bytes` ciphertext column. Returns `null` + * for a null / undefined / empty value so an absent note stores nothing (and + * the row reads back as "no note"). Whitespace is preserved verbatim — callers + * that want trimming trim before calling. + */ +export function encryptNote( + plaintext: string | null | undefined, +): Uint8Array | null { + if (plaintext === null || plaintext === undefined || plaintext.length === 0) { + return null; + } + return encryptToBytes(plaintext); +} + +/** + * Read a free-text note, preferring the encrypted column. Falls back to the + * legacy plaintext column ONLY when there is no ciphertext (a row the backfill + * has not yet migrated). A present ciphertext is always decrypted; a bad key + * id / malformed payload throws (fail-closed) rather than leaking the plaintext + * fallback. + */ +export function readNote( + ciphertext: Uint8Array | null | undefined, + plaintextFallback: string | null | undefined, +): string | null { + if (ciphertext && ciphertext.byteLength > 0) { + return decryptFromBytes(ciphertext); + } + return plaintextFallback ?? null; +} + +/** + * Shape a measurement row for a response/export: surface the decrypted note on + * `notes` and strip the raw `notesEncrypted` ciphertext so it never leaves the + * server. Keeps every other column untouched. + */ +export function shapeMeasurementNotes< + T extends { notes: string | null; notesEncrypted: Uint8Array | null }, +>(row: T): Omit & { notes: string | null } { + const { notesEncrypted, ...rest } = row; + return { ...rest, notes: readNote(notesEncrypted, rest.notes) }; +} + +/** + * Shape a mood row for a response/export: surface the decrypted note on `note` + * and strip the raw `noteEncrypted` ciphertext. Keeps every other column + * untouched. + */ +export function shapeMoodNote< + T extends { note: string | null; noteEncrypted: Uint8Array | null }, +>(row: T): Omit & { note: string | null } { + const { noteEncrypted, ...rest } = row; + return { ...rest, note: readNote(noteEncrypted, rest.note) }; +} diff --git a/src/lib/jobs/__tests__/note-encryption-backfill.test.ts b/src/lib/jobs/__tests__/note-encryption-backfill.test.ts new file mode 100644 index 000000000..eba8c418d --- /dev/null +++ b/src/lib/jobs/__tests__/note-encryption-backfill.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { readNote } from "@/lib/crypto/note-cipher"; + +// ── in-memory store backing a minimal prisma mock ────────────────────────── +interface MeasRow { + id: string; + userId: string; + notes: string | null; + notesEncrypted: Uint8Array | null; +} +interface MoodRow { + id: string; + userId: string; + note: string | null; + noteEncrypted: Uint8Array | null; +} + +const store = vi.hoisted(() => ({ + measurements: [] as MeasRow[], + mood: [] as MoodRow[], +})); + +// A `notes`/`note` write that is meant to throw on encrypt (simulates a +// fail-closed key error) so we can assert no data loss. +const fail = vi.hoisted(() => ({ encryptThrows: false })); + +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/db", () => { + // `delegates` is fully typed before `$transaction` closes over it, so the + // interactive-transaction mock has no self-referential `typeof prisma`. + const delegates = { + measurement: { + findMany: async (args: { where: { userId: string }; take: number }) => + store.measurements + .filter( + (r) => + r.userId === args.where.userId && + r.notes !== null && + r.notesEncrypted === null, + ) + .slice(0, args.take) + .map((r) => ({ id: r.id })), + findUnique: async (args: { where: { id: string } }) => { + const r = store.measurements.find((x) => x.id === args.where.id); + return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null; + }, + update: async (args: { + where: { id: string }; + data: { notes: string | null; notesEncrypted: Uint8Array | null }; + }) => { + const r = store.measurements.find((x) => x.id === args.where.id)!; + r.notes = args.data.notes; + r.notesEncrypted = args.data.notesEncrypted; + return r; + }, + }, + moodEntry: { + findMany: async (args: { where: { userId: string }; take: number }) => + store.mood + .filter( + (r) => + r.userId === args.where.userId && + r.note !== null && + r.noteEncrypted === null, + ) + .slice(0, args.take) + .map((r) => ({ id: r.id })), + findUnique: async (args: { where: { id: string } }) => { + const r = store.mood.find((x) => x.id === args.where.id); + return r ? { note: r.note, noteEncrypted: r.noteEncrypted } : null; + }, + update: async (args: { + where: { id: string }; + data: { note: string | null; noteEncrypted: Uint8Array | null }; + }) => { + const r = store.mood.find((x) => x.id === args.where.id)!; + r.note = args.data.note; + r.noteEncrypted = args.data.noteEncrypted; + return r; + }, + }, + }; + const prisma = { + ...delegates, + $transaction: async (fn: (tx: typeof delegates) => unknown) => + fn(delegates), + }; + return { prisma }; +}); + +import { runNoteEncryptionBackfillForUser } from "@/lib/jobs/note-encryption-backfill"; + +const KEY = "a".repeat(64); + +beforeEach(() => { + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", KEY); + store.measurements = [ + { id: "m1", userId: "u1", notes: "felt dizzy", notesEncrypted: null }, + { id: "m2", userId: "u1", notes: null, notesEncrypted: null }, // no note + { + id: "m3", + userId: "u1", + notes: null, + notesEncrypted: new Uint8Array(0), // already migrated-ish; left alone + }, + ]; + store.mood = [ + { id: "x1", userId: "u1", note: "rough night", noteEncrypted: null }, + { id: "x2", userId: "u2", note: "other user", noteEncrypted: null }, + ]; + fail.encryptThrows = false; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("runNoteEncryptionBackfillForUser", () => { + it("encrypts the plaintext note, nulls the plaintext, and round-trips", async () => { + const summary = await runNoteEncryptionBackfillForUser("u1"); + + expect(summary.measurementsMigrated).toBe(1); + expect(summary.moodEntriesMigrated).toBe(1); + + const m1 = store.measurements.find((r) => r.id === "m1")!; + expect(m1.notes).toBeNull(); + expect(m1.notesEncrypted).not.toBeNull(); + // No data loss: the ciphertext decrypts back to the original. + expect(readNote(m1.notesEncrypted, null)).toBe("felt dizzy"); + + const x1 = store.mood.find((r) => r.id === "x1")!; + expect(x1.note).toBeNull(); + expect(readNote(x1.noteEncrypted, null)).toBe("rough night"); + }); + + it("leaves note-less rows untouched and never writes a both-null content row", async () => { + await runNoteEncryptionBackfillForUser("u1"); + const m2 = store.measurements.find((r) => r.id === "m2")!; + expect(m2.notes).toBeNull(); + expect(m2.notesEncrypted).toBeNull(); // never had content -> nothing written + }); + + it("does not touch another user's rows", async () => { + await runNoteEncryptionBackfillForUser("u1"); + const x2 = store.mood.find((r) => r.id === "x2")!; + expect(x2.note).toBe("other user"); + expect(x2.noteEncrypted).toBeNull(); + }); + + it("is idempotent: a second run migrates zero rows and keeps one ciphertext", async () => { + await runNoteEncryptionBackfillForUser("u1"); + const m1First = store.measurements.find( + (r) => r.id === "m1", + )!.notesEncrypted; + + const second = await runNoteEncryptionBackfillForUser("u1"); + expect(second.measurementsMigrated).toBe(0); + expect(second.moodEntriesMigrated).toBe(0); + + const m1 = store.measurements.find((r) => r.id === "m1")!; + // Unchanged ciphertext, plaintext still null, value still recoverable. + expect(m1.notesEncrypted).toBe(m1First); + expect(m1.notes).toBeNull(); + expect(readNote(m1.notesEncrypted, null)).toBe("felt dizzy"); + }); + + it("is fail-closed: a key error leaves the plaintext row intact (no data loss)", async () => { + // Unset every key so encryptNote throws inside the per-row transaction. + vi.stubEnv("ENCRYPTION_KEY", ""); + vi.stubEnv("ENCRYPTION_KEYS", ""); + await expect(runNoteEncryptionBackfillForUser("u1")).rejects.toThrow(); + const m1 = store.measurements.find((r) => r.id === "m1")!; + // The transaction rolled back: plaintext preserved, no ciphertext written. + expect(m1.notes).toBe("felt dizzy"); + expect(m1.notesEncrypted).toBeNull(); + }); +}); diff --git a/src/lib/jobs/note-encryption-backfill.ts b/src/lib/jobs/note-encryption-backfill.ts new file mode 100644 index 000000000..1d4c5ede8 --- /dev/null +++ b/src/lib/jobs/note-encryption-backfill.ts @@ -0,0 +1,237 @@ +/** + * v1.23 — boot-time converging backfill that migrates the two free-text + * health-note columns from plaintext to AES-256-GCM at rest: + * `MoodEntry.note` -> `MoodEntry.noteEncrypted` and + * `Measurement.notes` -> `Measurement.notesEncrypted`. + * + * Modelled on `mean-consolidation.ts` / `rollup-full-backfill`: a discovery + * query enqueues one job per user still holding an un-migrated row, the + * per-user handler walks that user's rows, and the pass is idempotent across + * reboots — once a row is migrated it drops off the discovery + candidate sets. + * + * DATA-LOSS SAFETY (the property the security review must confirm): + * - Per row, the encrypt-then-null happens in a SINGLE interactive + * transaction guarded by a re-read inside the tx, so the plaintext column is + * only nulled AFTER the ciphertext is committed in the same atomic unit. A + * row that had content is never left both-null. + * - FAIL-CLOSED: `encryptNote` throws on a missing / malformed key. The throw + * aborts the transaction (the plaintext row is untouched) and propagates so + * pg-boss retries — the backfill never silently drops a note or writes a + * plaintext-shaped value into the ciphertext column. + * - IDEMPOTENT: the per-row guard (`notesEncrypted IS NULL AND notes != null`) + * is re-checked inside the tx, so a re-run — or two workers racing — migrates + * a row at most once; a second pass migrates zero rows. + * + * The legacy plaintext columns are intentionally NOT dropped in this release. + * That is a deliberate FOLLOW-UP release boundary, once this backfill reports + * zero remaining un-migrated rows on every instance — the same discipline the + * encryption-key-rotation playbook uses before dropping a legacy key. + * + * The queue name MUST be registered in the maintenance registrar + * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it at + * boot; an unregistered queue silently never drains. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { encryptNote } from "@/lib/crypto/note-cipher"; + +export const NOTE_ENCRYPTION_BACKFILL_QUEUE = "note-encryption-backfill"; + +/** + * Serial concurrency — the populator walks a user's plaintext-note rows and + * writes one transaction per row; concurrency-1 keeps it off the request pool, + * matching the consolidation / rollup-backfill convention. + */ +export const NOTE_ENCRYPTION_BACKFILL_CONCURRENCY = 1; + +/** How many candidate rows to pull per page within a user's pass. */ +const PAGE_SIZE = 200; + +export interface NoteEncryptionBackfillPayload { + /** Absent on the daily discovery tick; present for a per-user job. */ + userId?: string; + enqueuedAt?: string; +} + +export interface NoteEncryptionBackfillSummary { + measurementsMigrated: number; + moodEntriesMigrated: number; +} + +/** + * Migrate one measurement row's plaintext `notes` into `notesEncrypted` and + * null the plaintext — atomically and idempotently. Returns true if it + * migrated the row, false if a concurrent pass already did. + */ +async function migrateMeasurementRow(id: string): Promise { + return prisma.$transaction(async (tx) => { + const fresh = await tx.measurement.findUnique({ + where: { id }, + select: { notes: true, notesEncrypted: true }, + }); + // Already migrated (ciphertext present) or nothing to migrate. + if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false; + // FAIL-CLOSED: a key error throws here and rolls the tx back, leaving the + // plaintext row untouched. + const encrypted = encryptNote(fresh.notes); + await tx.measurement.update({ + where: { id }, + data: { notesEncrypted: encrypted, notes: null }, + }); + return true; + }); +} + +/** + * Migrate one mood row's plaintext `note` into `noteEncrypted` and null the + * plaintext — atomically and idempotently. + */ +async function migrateMoodRow(id: string): Promise { + return prisma.$transaction(async (tx) => { + const fresh = await tx.moodEntry.findUnique({ + where: { id }, + select: { note: true, noteEncrypted: true }, + }); + if (!fresh || fresh.noteEncrypted || fresh.note === null) return false; + const encrypted = encryptNote(fresh.note); + await tx.moodEntry.update({ + where: { id }, + data: { noteEncrypted: encrypted, note: null }, + }); + return true; + }); +} + +/** + * Per-user queue handler. Walks every un-migrated measurement + mood note for + * one account and migrates each in its own transaction. Safe to re-run. + */ +export async function runNoteEncryptionBackfillForUser( + userId: string, +): Promise { + let measurementsMigrated = 0; + let moodEntriesMigrated = 0; + + // Measurements — page through candidates until the predicate is empty. + // Each migration nulls `notes`, so a migrated row drops out of the next + // `notes: { not: null }, notesEncrypted: null` page. + for (;;) { + const rows = await prisma.measurement.findMany({ + where: { userId, notes: { not: null }, notesEncrypted: null }, + select: { id: true }, + take: PAGE_SIZE, + }); + if (rows.length === 0) break; + let migratedThisPage = 0; + for (const { id } of rows) { + if (await migrateMeasurementRow(id)) { + measurementsMigrated += 1; + migratedThisPage += 1; + } + } + // Guard against an unexpected non-converging page (every row already + // migrated by a racing worker) so the loop always terminates. + if (migratedThisPage === 0) break; + } + + for (;;) { + const rows = await prisma.moodEntry.findMany({ + where: { userId, note: { not: null }, noteEncrypted: null }, + select: { id: true }, + take: PAGE_SIZE, + }); + if (rows.length === 0) break; + let migratedThisPage = 0; + for (const { id } of rows) { + if (await migrateMoodRow(id)) { + moodEntriesMigrated += 1; + migratedThisPage += 1; + } + } + if (migratedThisPage === 0) break; + } + + annotate({ + action: { + name: "note.encryption.backfill", + details: { + measurements_migrated: measurementsMigrated, + mood_entries_migrated: moodEntriesMigrated, + }, + }, + }); + + return { measurementsMigrated, moodEntriesMigrated }; +} + +/** + * Boot-time discovery. Finds every user holding at least one row whose note is + * still in the legacy plaintext column (no ciphertext yet) across either table + * and enqueues one backfill job per account. + * + * Idempotent across reboots: once a user's rows are migrated, the + * `*Encrypted IS NULL AND * != null` predicate drops them from the discovery + * set. pg-boss `singletonKey` coalesces duplicate sends. Best-effort: errors + * come back through the result value so worker boot never fails on a miss. + */ +export async function enqueueBootTimeNoteEncryptionBackfill(): Promise<{ + enqueued: number; + skipped: number; + error: string | null; +}> { + const boss = getGlobalBoss(); + if (!boss) { + return { enqueued: 0, skipped: 0, error: null }; + } + + try { + const [measurementUsers, moodUsers] = await Promise.all([ + prisma.measurement.findMany({ + where: { notes: { not: null }, notesEncrypted: null }, + select: { userId: true }, + distinct: ["userId"], + }), + prisma.moodEntry.findMany({ + where: { note: { not: null }, noteEncrypted: null }, + select: { userId: true }, + distinct: ["userId"], + }), + ]); + + const userIds = new Set(); + for (const { userId } of measurementUsers) userIds.add(userId); + for (const { userId } of moodUsers) userIds.add(userId); + + if (userIds.size === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + let enqueued = 0; + let skipped = 0; + for (const userId of userIds) { + const payload: NoteEncryptionBackfillPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send(NOTE_ENCRYPTION_BACKFILL_QUEUE, payload, { + retryLimit: 5, + retryDelay: 60, + retryBackoff: true, + singletonKey: `note-encryption-backfill|${userId}`, + }); + if (jobId) { + enqueued += 1; + } else { + skipped += 1; + } + } + return { enqueued, skipped, error: null }; + } catch (err) { + return { + enqueued: 0, + skipped: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index ceeeccecd..4f257d469 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -7,6 +7,7 @@ import { type Job } from "pg-boss"; import { recordError } from "@/lib/jobs/worker-status"; import { encrypt } from "@/lib/crypto"; +import { readNote } from "@/lib/crypto/note-cipher"; import { withBackgroundEvent } from "@/lib/logging/background"; import { buildCycleBackupSection } from "@/lib/cycle/backup"; import { runOffhostBackup } from "@/lib/jobs/offhost-backup"; @@ -94,7 +95,9 @@ export async function handleDataBackup(jobs: Job[]) { unit: m.unit, measuredAt: m.measuredAt.toISOString(), source: m.source, - notes: m.notes, + // v1.23 — decrypt into the (whole-blob-encrypted) backup payload + // so an admin restore re-encrypts on re-insert. + notes: readNote(m.notesEncrypted, m.notes), })), medications: medications.map((m) => ({ name: m.name, diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index e003d1d2a..b5ca7874e 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -55,6 +55,13 @@ import { RESTORE_DRILL_CRON, RESTORE_DRILL_QUEUE, } from "@/lib/jobs/restore-drill"; +import { + NOTE_ENCRYPTION_BACKFILL_QUEUE, + NOTE_ENCRYPTION_BACKFILL_CONCURRENCY, + runNoteEncryptionBackfillForUser, + enqueueBootTimeNoteEncryptionBackfill, + type NoteEncryptionBackfillPayload, +} from "@/lib/jobs/note-encryption-backfill"; import { recordError } from "@/lib/jobs/worker-status"; import { workerLog } from "./shared"; import { createAndSchedule, type ScheduleEntry } from "./registrar-shared"; @@ -168,6 +175,13 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *"; const COACH_MESSAGE_CLEANUP_QUEUE = "coach-message-cleanup"; const COACH_MESSAGE_CLEANUP_CRON = "50 3 * * *"; +// v1.23 — converging backfill that migrates the free-text health-note columns +// (mood + measurement) from plaintext to AES-256-GCM at rest. Boot discovery +// enqueues one per-user job; a daily 03:55 Europe/Berlin discovery tick (empty +// payload) re-fans for any rows that landed between worker reboots, so the +// migration converges without a restart. Idempotent + fail-closed per row. + +const NOTE_ENCRYPTION_BACKFILL_CRON = "55 3 * * *"; const allQueues = [ DATA_BACKUP_QUEUE, @@ -209,6 +223,11 @@ const allQueues = [ // the daily schedule silently no-ops and the encrypted coach_messages // table grows unbounded. COACH_MESSAGE_CLEANUP_QUEUE, + // v1.23 — free-text health-note encryption backfill. Boot discovery + a + // daily discovery cron enqueue one per-user job per account still holding a + // plaintext note; without this entry pg-boss never provisions the queue and + // both the boot enqueue and the cron silently no-op. + NOTE_ENCRYPTION_BACKFILL_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -260,6 +279,10 @@ const schedules: ScheduleEntry[] = [ [MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON], // v1.18.7 — daily 03:50 Europe/Berlin prune for stale Coach history. [COACH_MESSAGE_CLEANUP_QUEUE, COACH_MESSAGE_CLEANUP_CRON], + // v1.23 — daily 03:55 Europe/Berlin note-encryption backfill discovery. + // The empty cron payload (no `userId`) is the handler's signal to fan out + // one per-user job per account still holding a plaintext note. + [NOTE_ENCRYPTION_BACKFILL_QUEUE, NOTE_ENCRYPTION_BACKFILL_CRON], ]; /** @@ -427,6 +450,46 @@ export async function registerMaintenanceQueues( }, ); + // v1.23 — note-encryption backfill worker. The boot enqueue helper (and the + // daily 03:55 discovery tick) send one job per user still holding a plaintext + // note; this handler migrates that user's rows to the encrypted columns. An + // empty-userId payload is the daily discovery tick and re-fans the per-user + // jobs (singletonKey-coalesced, identical to the boot pass). Serial + // concurrency so the migration never crowds the request pool. + await boss.work>( + NOTE_ENCRYPTION_BACKFILL_QUEUE, + { localConcurrency: NOTE_ENCRYPTION_BACKFILL_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + const result = await enqueueBootTimeNoteEncryptionBackfill(); + workerLog( + "info", + `[note-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, + ); + continue; + } + try { + const { measurementsMigrated, moodEntriesMigrated } = + await runNoteEncryptionBackfillForUser(userId); + workerLog( + "info", + `[note-encryption-backfill] user=${userId} measurements=${measurementsMigrated} moodEntries=${moodEntriesMigrated}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[note-encryption-backfill] user=${userId} failed`, + err, + ); + throw err; + } + } + }, + ); + return allQueues; } @@ -456,4 +519,30 @@ export async function enqueueMaintenanceBootDiscovery(): Promise { err, ); } + + // v1.23 — note-encryption backfill. Finds every user still holding a + // plaintext mood/measurement note (no ciphertext yet) and enqueues one + // migration job per account. Idempotent across reboots: a migrated row nulls + // its plaintext column and drops off the discovery predicate. + try { + const { enqueued, skipped, error } = + await enqueueBootTimeNoteEncryptionBackfill(); + if (error) { + workerLog( + "error", + `[note-encryption-backfill] boot discovery failed: ${error}`, + ); + } else { + workerLog( + "info", + `[note-encryption-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[note-encryption-backfill] boot discovery threw an unexpected error", + err, + ); + } } diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index 6aaa70f0b..569e11aaa 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -96,8 +96,12 @@ export function redactSecrets(input: string): string { // v1.12.2 — `ticket` (the one-time WHOOP connect ticket rides in the // `GET /api/whoop/connect?ticket=…` URL): scrub it so the opaque value // never lands in `http.path`/error strings reaching Loki/Glitchtip. + // v1.23 — second-factor / account-recovery / encrypted-export material. + // `totp`/`mfa` tickets-or-codes, `backup_code` / `recovery_code`, and the + // `passphrase` that derives the encrypted-export key must never land in + // `http.path` / error strings reaching Loki / Glitchtip. .replace( - /([?&])(secret|code|token|ticket|api[_-]?key|insurance(?:number)?|kvnr)=[^&\s]+/gi, + /([?&])(secret|code|token|ticket|api[_-]?key|insurance(?:number)?|kvnr|totp|mfa|backup[_-]?code|recovery[_-]?code|passphrase)=[^&\s]+/gi, "$1$2=[REDACTED]", ) ); diff --git a/src/lib/mood/__tests__/create-from-telegram.test.ts b/src/lib/mood/__tests__/create-from-telegram.test.ts index e36f4caa8..b0c56846c 100644 --- a/src/lib/mood/__tests__/create-from-telegram.test.ts +++ b/src/lib/mood/__tests__/create-from-telegram.test.ts @@ -28,6 +28,23 @@ vi.mock("@/lib/logging/context", () => ({ getEvent: () => ({ addMeta: vi.fn() }), })); +// v1.23 — deterministic note-cipher so the unit test stays isolated from the +// encryption-key env (mirrors the illness day-log-write test's bytes-codec +// mock). `enc:` stands in for the AES-256-GCM ciphertext. +vi.mock("@/lib/crypto/note-cipher", () => ({ + encryptNote: (s: string | null | undefined) => + s === null || s === undefined || s.length === 0 + ? null + : new Uint8Array(Buffer.from(`enc:${s}`, "utf8")), + readNote: ( + cipher: Uint8Array | null | undefined, + plain: string | null | undefined, + ) => + cipher && cipher.byteLength > 0 + ? Buffer.from(cipher).toString("utf8").replace(/^enc:/, "") + : (plain ?? null), +})); + import { logTelegramMood, attachTelegramMoodNote, @@ -106,7 +123,11 @@ describe("attachTelegramMoodNote", () => { userId: "user-1", deletedAt: null, }); - expect(arg.data.note).toBe("rough night"); + // v1.23 — the note now rides the encrypted column; the plaintext is nulled. + expect(arg.data.note).toBeNull(); + expect(Buffer.from(arg.data.noteEncrypted).toString("utf8")).toBe( + "enc:rough night", + ); expect(invalidateUserMood).toHaveBeenCalledWith("user-1"); }); diff --git a/src/lib/mood/create-from-telegram.ts b/src/lib/mood/create-from-telegram.ts index d6610fe15..c475ffb89 100644 --- a/src/lib/mood/create-from-telegram.ts +++ b/src/lib/mood/create-from-telegram.ts @@ -18,6 +18,7 @@ import { prisma as defaultPrisma } from "@/lib/db"; import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; import { getScoreForMood } from "@/lib/validations/moodlog"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { encryptNote, readNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; @@ -79,12 +80,21 @@ export async function logTelegramMood(input: { tz, mood, score, - note: input.note ?? null, + // v1.23 — encrypt the note at rest; legacy plaintext column nulled. + note: null, + noteEncrypted: encryptNote(input.note ?? null), source: "TELEGRAM", externalId: input.externalId, moodLoggedAt, }, - select: { id: true, date: true, mood: true, note: true, tags: true }, + select: { + id: true, + date: true, + mood: true, + note: true, + noteEncrypted: true, + tags: true, + }, }); invalidateUserMood(input.userId); @@ -105,7 +115,7 @@ export async function logTelegramMood(input: { date: entry.date, moodLoggedAt, mood: entry.mood, - note: entry.note ?? null, + note: readNote(entry.noteEncrypted, entry.note), tags: entry.tags, source: "TELEGRAM", }, @@ -134,7 +144,8 @@ export async function attachTelegramMoodNote(input: { userId: input.userId, deletedAt: null, }, - data: { note }, + // v1.23 — encrypt the attached note at rest; legacy plaintext column nulled. + data: { note: null, noteEncrypted: encryptNote(note) }, }); if (updated.count === 0) return false; invalidateUserMood(input.userId); diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts index f9dd8b9be..8e537041c 100644 --- a/src/lib/observability/redact-payload.ts +++ b/src/lib/observability/redact-payload.ts @@ -43,6 +43,16 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [ /otp/i, /recovery/i, /dsn/i, + // v1.23 — second-factor / account-recovery material. `/otp/i` already + // catches `totp`/`otpauth` and `/recovery/i` already catches + // `recoveryCode`, but the explicit `/totp/i`, `/mfa/i`, `/backup.?code/i`, + // `/recovery.?code/i` keep MFA secrets / recovery codes / the TOTP + // `otpauth://` URI off every wide-event excerpt regardless of field + // spelling. `passphrase` (the encrypted-export key) is covered above. + /totp/i, + /mfa/i, + /backup.?code/i, + /recovery.?code/i, // v1.7.0 — health-insurance identity. `insuranceNumber` / `kvnr` // (the German statutory-insurance number) is encrypted at rest and is // absent from every current wide-event excerpt, but the field name diff --git a/tests/integration/mood-entries-note-column.test.ts b/tests/integration/mood-entries-note-column.test.ts index 0e810855f..3fb786d07 100644 --- a/tests/integration/mood-entries-note-column.test.ts +++ b/tests/integration/mood-entries-note-column.test.ts @@ -1,15 +1,19 @@ /** - * v1.4.30 — `MoodEntry.note` column round-trip integration. + * v1.4.30 / v1.23 — mood-note round-trip integration. + * + * v1.23 moved the free-text note to AES-256-GCM at rest: new writes land in + * `noteEncrypted` (Bytes) and the legacy plaintext `note` column is nulled. * * Asserts: - * - POST /api/mood-entries accepts the new `note` field - * - PUT /api/mood-entries/[id] updates `note` (and accepts null to clear) - * - POST /api/mood-entries/bulk persists `note` per entry + * - POST /api/mood-entries encrypts the `note` (plaintext column nulled) + * - PUT /api/mood-entries/[id] re-encrypts `note` (and accepts null to clear) + * - POST /api/mood-entries/bulk encrypts `note` per entry * - The Zod cap (500 chars) rejects oversize prose */ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { readNote } from "@/lib/crypto/note-cipher"; import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -87,10 +91,18 @@ describe("MoodEntry.note round-trip (real Postgres)", () => { }), ); expect(res.status).toBe(201); + // The response carries the decrypted note back to the client. + const body = (await res.json()) as { data: { note: string | null } }; + expect(body.data.note).toBe("Long run before breakfast."); const stored = await getPrismaClient().moodEntry.findFirst({ where: { userId: TEST_USER_ID }, }); - expect(stored?.note).toBe("Long run before breakfast."); + // At rest: plaintext column nulled, ciphertext present + decrypts back. + expect(stored?.note).toBeNull(); + expect(stored?.noteEncrypted).not.toBeNull(); + expect(readNote(stored?.noteEncrypted ?? null, null)).toBe( + "Long run before breakfast.", + ); }); it("PUT /api/mood-entries/[id] updates `note` and accepts null to clear it", async () => { @@ -115,7 +127,8 @@ describe("MoodEntry.note round-trip (real Postgres)", () => { const after1 = await getPrismaClient().moodEntry.findUnique({ where: { id: created.id }, }); - expect(after1?.note).toBe("revised note"); + expect(after1?.note).toBeNull(); + expect(readNote(after1?.noteEncrypted ?? null, null)).toBe("revised note"); const res2 = await PUT( putRequest(`/api/mood-entries/${created.id}`, { note: null }), @@ -125,7 +138,9 @@ describe("MoodEntry.note round-trip (real Postgres)", () => { const after2 = await getPrismaClient().moodEntry.findUnique({ where: { id: created.id }, }); + // Cleared: both the plaintext and the ciphertext column are null. expect(after2?.note).toBeNull(); + expect(after2?.noteEncrypted).toBeNull(); }); it("POST /api/mood-entries/bulk persists `note` per entry", async () => { @@ -151,7 +166,12 @@ describe("MoodEntry.note round-trip (real Postgres)", () => { where: { userId: TEST_USER_ID }, orderBy: { moodLoggedAt: "asc" }, }); - expect(stored.map((r) => r.note)).toEqual(["Morning", "Afternoon dip"]); + // Plaintext column nulled; ciphertext decrypts back to each note. + expect(stored.map((r) => r.note)).toEqual([null, null]); + expect(stored.map((r) => readNote(r.noteEncrypted, null))).toEqual([ + "Morning", + "Afternoon dip", + ]); }); it("rejects an oversize note (501 chars) with 422", async () => { diff --git a/tests/integration/note-encryption-backfill.test.ts b/tests/integration/note-encryption-backfill.test.ts new file mode 100644 index 000000000..0246c8e17 --- /dev/null +++ b/tests/integration/note-encryption-backfill.test.ts @@ -0,0 +1,124 @@ +/** + * v1.23 — free-text health-note encryption backfill (real Postgres). + * + * Seeds legacy plaintext rows (note in the plaintext column, ciphertext null), + * runs the per-user backfill, and asserts: + * - the plaintext is encrypted into `*Encrypted` and the plaintext column is + * nulled, in one transaction (no row left both-null when it had content); + * - the ciphertext decrypts back to the original (no data loss); + * - a note-less row is untouched; + * - a second run is a no-op (idempotent). + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { readNote } from "@/lib/crypto/note-cipher"; +import { runNoteEncryptionBackfillForUser } from "@/lib/jobs/note-encryption-backfill"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-note-backfill"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "note-backfill", + email: "note-backfill@example.test", + timezone: "Europe/Berlin", + }, + }); +}); + +describe("note-encryption backfill (real Postgres)", () => { + it("migrates legacy plaintext notes to ciphertext and nulls the plaintext", async () => { + const prisma = getPrismaClient(); + // Legacy rows: plaintext present, ciphertext null (the pre-v1.23 shape). + const withNote = await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + notes: "felt dizzy after", + }, + }); + const noNote = await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "WEIGHT", + value: 81, + unit: "kg", + measuredAt: new Date("2026-05-17T08:00:00.000Z"), + notes: null, + }, + }); + const mood = await prisma.moodEntry.create({ + data: { + userId: TEST_USER_ID, + date: "2026-05-16", + tz: "Europe/Berlin", + mood: "OKAY", + score: 3, + moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"), + note: "rough night", + }, + }); + + const summary = await runNoteEncryptionBackfillForUser(TEST_USER_ID); + expect(summary.measurementsMigrated).toBe(1); + expect(summary.moodEntriesMigrated).toBe(1); + + const m = await prisma.measurement.findUnique({ + where: { id: withNote.id }, + }); + expect(m?.notes).toBeNull(); + expect(m?.notesEncrypted).not.toBeNull(); + expect(readNote(m?.notesEncrypted ?? null, null)).toBe("felt dizzy after"); + + const untouched = await prisma.measurement.findUnique({ + where: { id: noNote.id }, + }); + expect(untouched?.notes).toBeNull(); + expect(untouched?.notesEncrypted).toBeNull(); + + const x = await prisma.moodEntry.findUnique({ where: { id: mood.id } }); + expect(x?.note).toBeNull(); + expect(readNote(x?.noteEncrypted ?? null, null)).toBe("rough night"); + }); + + it("is idempotent: a second run migrates zero rows and preserves the data", async () => { + const prisma = getPrismaClient(); + const row = await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + notes: "side effect note", + }, + }); + + await runNoteEncryptionBackfillForUser(TEST_USER_ID); + const first = await prisma.measurement.findUnique({ + where: { id: row.id }, + }); + const firstCipher = Buffer.from(first?.notesEncrypted ?? new Uint8Array()); + + const second = await runNoteEncryptionBackfillForUser(TEST_USER_ID); + expect(second.measurementsMigrated).toBe(0); + + const after = await prisma.measurement.findUnique({ + where: { id: row.id }, + }); + // Same ciphertext, plaintext still null, note still recoverable. + expect(Buffer.from(after?.notesEncrypted ?? new Uint8Array())).toEqual( + firstCipher, + ); + expect(after?.notes).toBeNull(); + expect(readNote(after?.notesEncrypted ?? null, null)).toBe( + "side effect note", + ); + }); +}); diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index c07b77a7d..4160d500b 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -16,6 +16,14 @@ import { prisma } from "@/lib/db"; import type { PrismaClient } from "@/generated/prisma/client"; +// v1.23 — every integration test imports this module. The free-text health-note +// columns (mood + measurement) are now AES-256-GCM at rest, so any test that +// writes/reads a note needs an encryption key. Crypto reads the key lazily on +// first `encrypt()`, so a `??=` default here covers every test without touching +// each file (individual tests that set their own key still win — `??=`). +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + /** * The application's Prisma singleton. Tests use this so any code * imported via `await import("@/lib/...")` shares the exact same client