From 4d3a02cc1adb4bd25e7421ff0c793c974bf8ae0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 19:33:10 +0200 Subject: [PATCH 01/34] feat(security): add second-factor schema and encrypt free-text health notes at rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lay the account-security and data-sovereignty substrate for v1.23. No user-facing behaviour yet — schema, crypto registry, redaction, and the note-encryption data path only. Schema + migrations: - 0199 — User totp_secret_encrypted / totp_confirmed_at / mfa_enforced / passkey_upgrade_nudge_dismissed; Session mfa_verified_at / last_active_at; Passkey last_used_at. All additive, nullable / defaulted. - 0200 — mfa_challenges (single-use step-up ticket; only the ticket hash is stored, so no session or token is ever minted before the second factor), mfa_recovery_codes (Argon2id at rest), webauthn_mfa_credentials (second-factor keys, kept separate from passkeys, which remain the passwordless-primary set). - 0201 — mood_entries.note_encrypted + measurements.notes_encrypted (BYTEA), matching the existing free-text encrypted-note convention. Free-text health notes: - New writes encrypt the mood / measurement note into the *_encrypted column and null the legacy plaintext column; reads prefer the ciphertext and fall back to plaintext only for not-yet-migrated rows, via a single read/write helper. - A boot-time job migrates existing plaintext rows in an idempotent, transactional, fail-closed pass (encrypt, then null the plaintext, per row in one transaction; safe to re-run; a row with content is never left both-null). - The legacy plaintext columns are kept this release and dropped in a follow-up once the backfill reports zero remaining rows — the same boundary the key-rotation playbook uses before dropping a legacy key. Crypto coverage: - Register the three new encrypted columns and wire them into the key-rotation script so a rotation never orphans them. - Extend the observability and log redaction denylists so TOTP secrets, MFA / recovery / backup codes, and export passphrases never reach a wide event. --- .../0199_v123_mfa_columns/migration.sql | 24 + .../0200_v123_mfa_tables/migration.sql | 69 ++ .../0201_v123_note_encryption/migration.sql | 28 + prisma/schema.prisma | 757 ++++++++++-------- scripts/rotate-encryption-key.ts | 18 + .../api/admin/backups/[id]/restore/route.ts | 6 +- src/app/api/export/full-backup/route.ts | 5 +- src/app/api/export/measurements/route.ts | 17 +- src/app/api/export/mood/route.ts | 5 +- src/app/api/export/route.ts | 9 +- .../api/import/csv/__tests__/route.test.ts | 9 + src/app/api/import/csv/route.ts | 10 +- src/app/api/import/route.ts | 10 +- src/app/api/measurements/[id]/route.ts | 10 +- src/app/api/measurements/route.ts | 20 +- src/app/api/mood-entries/[id]/route.ts | 8 +- src/app/api/mood-entries/bulk/route.ts | 7 +- src/app/api/mood-entries/route.ts | 18 +- src/app/api/sync/changes/route.ts | 7 +- src/lib/crypto/__tests__/note-cipher.test.ts | 104 +++ src/lib/crypto/encrypted-columns.ts | 9 + src/lib/crypto/note-cipher.ts | 75 ++ .../note-encryption-backfill.test.ts | 181 +++++ src/lib/jobs/note-encryption-backfill.ts | 237 ++++++ src/lib/jobs/reminder/backup-handlers.ts | 5 +- src/lib/jobs/reminder/register-maintenance.ts | 89 ++ src/lib/logging/redact.ts | 6 +- .../__tests__/create-from-telegram.test.ts | 23 +- src/lib/mood/create-from-telegram.ts | 19 +- src/lib/observability/redact-payload.ts | 10 + .../mood-entries-note-column.test.ts | 34 +- .../note-encryption-backfill.test.ts | 124 +++ tests/integration/setup.ts | 8 + 33 files changed, 1583 insertions(+), 378 deletions(-) create mode 100644 prisma/migrations/0199_v123_mfa_columns/migration.sql create mode 100644 prisma/migrations/0200_v123_mfa_tables/migration.sql create mode 100644 prisma/migrations/0201_v123_note_encryption/migration.sql create mode 100644 src/lib/crypto/__tests__/note-cipher.test.ts create mode 100644 src/lib/crypto/note-cipher.ts create mode 100644 src/lib/jobs/__tests__/note-encryption-backfill.test.ts create mode 100644 src/lib/jobs/note-encryption-backfill.ts create mode 100644 tests/integration/note-encryption-backfill.test.ts 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 From 9399b77a74a6cd5cd4fe350b545941d3bc2c911d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 19:46:07 +0200 Subject: [PATCH 02/34] fix(measurements): shape the single-measurement note so ciphertext never leaves the server Wrap GET /api/measurements/:id through shapeMeasurementNotes like the list, create, and update paths already do, so the encrypted note column is decrypted into notes and the raw ciphertext is stripped from the response. Harden the rotation-coverage guard to require both the field literal and the model delegate, so a same-named encrypted column whose table is not rotated is caught. --- src/app/api/measurements/[id]/route.ts | 2 +- .../__tests__/encrypted-columns.test.ts | 25 ++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index f25ab7710..4354eb3c1 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -46,7 +46,7 @@ export const GET = apiHandler( }, }); - return apiSuccess(measurement); + return apiSuccess(shapeMeasurementNotes(measurement)); }, ); diff --git a/src/lib/crypto/__tests__/encrypted-columns.test.ts b/src/lib/crypto/__tests__/encrypted-columns.test.ts index 203873687..558b367a6 100644 --- a/src/lib/crypto/__tests__/encrypted-columns.test.ts +++ b/src/lib/crypto/__tests__/encrypted-columns.test.ts @@ -147,12 +147,29 @@ describe("encrypted-column registry", () => { it("is fully referenced by the key-rotation script", () => { const script = readFileSync(ROTATION_SCRIPT_PATH, "utf8"); - const unreferenced = ENCRYPTED_COLUMNS.filter( - (c: EncryptedColumn) => !script.includes(`"${c.field}"`), - ).map(encryptedColumnKey); + // Field names are NOT unique across models (e.g. `noteEncrypted` lives on + // five models, `notesEncrypted` on two, `accessToken`/`refreshToken` on + // three connection tables each). A bare field-name substring match passes + // on the first occurrence and is blind to a same-named sibling whose model + // was never wired into rotation — exactly the "silently skipped → + // undecryptable after the old key is dropped" failure the registry exists + // to prevent. So require, per registry entry, BOTH the field literal AND + // the model's Prisma delegate (`prisma.`) to appear in the script + // — the delegate proves that model's table is actually walked (some tables + // are rotated through a shared `for (const field of [...])` loop, where the + // field literal appears once but each delegate is named explicitly). + const delegate = (model: string) => + `prisma.${model.charAt(0).toLowerCase()}${model.slice(1)}`; + const unreferenced = (ENCRYPTED_COLUMNS as EncryptedColumn[]) + .filter( + (c) => + !script.includes(`"${c.field}"`) || + !script.includes(delegate(c.model)), + ) + .map(encryptedColumnKey); expect( unreferenced, - "registry columns NOT referenced by scripts/rotate-encryption-key.ts", + "registry columns NOT fully referenced (field + model delegate) by scripts/rotate-encryption-key.ts", ).toEqual([]); }); }); From 3d3018b12e8b298527a5245b89a2f318c36c75ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 19:56:02 +0200 Subject: [PATCH 03/34] feat(auth): add a fresh-MFA step-up gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add requireFreshMfa(maxAgeSeconds) and requireCookieAuth alongside requireAdmin. Both resolve through getSession() only, so a Bearer token can never satisfy them — the same structural boundary requireAdmin relies on, not a softenable runtime check. requireFreshMfa passes only for a cookie session whose Session.mfaVerifiedAt is within the window and whose user has an active second factor; otherwise it throws StepUpRequiredError (401 + meta.errorCode "auth.stepup.required") so the client can launch a re-verification flow. createSession now accepts an optional mfaVerifiedAt stamp so a session minted behind a completed second factor records its freshness. --- src/lib/__tests__/require-fresh-mfa.test.ts | 113 +++++++++++++++++++ src/lib/api-handler.ts | 116 ++++++++++++++++++++ src/lib/auth/session.ts | 6 + 3 files changed, 235 insertions(+) create mode 100644 src/lib/__tests__/require-fresh-mfa.test.ts diff --git a/src/lib/__tests__/require-fresh-mfa.test.ts b/src/lib/__tests__/require-fresh-mfa.test.ts new file mode 100644 index 000000000..8093ab688 --- /dev/null +++ b/src/lib/__tests__/require-fresh-mfa.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mirrors require-auth-bearer.test.ts mocking so the api-handler module loads +// without a live DB / request scope. +vi.mock("@/lib/db", () => ({ + prisma: { + session: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +const headersGet = vi.fn<(name: string) => string | null>(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: headersGet })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { + requireFreshMfa, + StepUpRequiredError, + HttpError, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "../api-handler"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const MFA_USER = { + id: "user-1", + role: "USER" as const, + username: "u", + totpConfirmedAt: new Date("2020-01-01"), +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +function mockSession(user: unknown) { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) }, + user, + } as never); +} + +describe("requireFreshMfa", () => { + it("passes for a cookie session verified within the window", async () => { + mockSession(MFA_USER); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(Date.now() - 60_000), // 1 min ago + } as never); + + const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + expect(ctx.user.id).toBe("user-1"); + expect(ctx.mfaVerifiedAt).toBeInstanceOf(Date); + }); + + it("rejects a stale verification (outside the window)", async () => { + mockSession(MFA_USER); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(Date.now() - 10 * 60_000), // 10 min ago + } as never); + + await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({ + name: "StepUpRequiredError", + errorCode: "auth.stepup.required", + statusCode: 401, + }); + }); + + it("rejects an absent verification stamp", async () => { + mockSession(MFA_USER); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: null, + } as never); + + await expect(requireFreshMfa(5 * 60)).rejects.toBeInstanceOf( + StepUpRequiredError, + ); + }); + + it("rejects a user without an active second factor", async () => { + mockSession({ ...MFA_USER, totpConfirmedAt: null }); + await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({ + errorCode: "auth.stepup.mfa_not_enrolled", + }); + // Never even reaches the session-row read. + expect(prisma.session.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects when there is no cookie session — Bearer can never satisfy it", async () => { + // getSession is cookie-only; a Bearer header is irrelevant and never read. + headersGet.mockImplementation((n) => + n.toLowerCase() === "authorization" ? "Bearer hlk_xyz" : null, + ); + vi.mocked(getSession).mockResolvedValue(null as never); + + await expect(requireFreshMfa(5 * 60)).rejects.toBeInstanceOf(HttpError); + expect(prisma.session.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 4733b9ac2..d0761558f 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -191,6 +191,21 @@ export function apiHandler Promise>( }, { status: 403 }, ); + } else if (error instanceof StepUpRequiredError) { + // v1.23 — step-up gate not satisfied. Same 401 + meta.errorCode + // envelope shape as the assistant/consent gates so the client can + // branch on the stable code and launch a re-verification flow. + // Checked before the generic HttpError branch because + // StepUpRequiredError extends it. + evt.setError(error); + response = NextResponse.json( + { + data: null, + error: error.message, + meta: { errorCode: error.errorCode }, + }, + { status: error.statusCode }, + ); } else if (error instanceof HttpError) { evt.setError(error); response = NextResponse.json( @@ -441,6 +456,107 @@ export async function requireAdmin(): Promise { return sessionData; } +/** + * v1.23 — require a cookie-backed session, refusing Bearer tokens. + * + * Cookie-only by the same structural argument as `requireAdmin`: it resolves + * the session via `getSession()` (which reads only the session cookie) and + * never falls through to the Bearer branch. The second-factor management + * surfaces (TOTP enroll / confirm / disable / recovery-code regenerate) use + * this so an API token — even a wildcard one — can never enrol or tear down + * MFA on the account it belongs to. MFA management is a browser-only action. + */ +export async function requireCookieAuth(): Promise { + const sessionData = await getSession(); + if (!sessionData) throw new HttpError(401, "Not authenticated"); + + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + return sessionData; +} + +/** + * Error thrown when a step-up gate is not satisfied. Carries `errorCode` so + * the route can surface a stable machine code (`auth.stepup.required`) the + * client branches on to launch a re-verification flow rather than parsing + * prose. + */ +export class StepUpRequiredError extends HttpError { + constructor( + public errorCode: string = "auth.stepup.required", + message = "Recent second-factor verification required", + ) { + super(401, message); + this.name = "StepUpRequiredError"; + } +} + +/** + * Default step-up freshness window (5 minutes) for sensitive mutations. + * Within the 5–15 min band OWASP recommends; tight end because the gated + * actions (disable MFA, regenerate codes, and later key rotation / export) + * are destructive. + */ +export const MFA_STEP_UP_MAX_AGE_SECONDS = 5 * 60; + +export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date }; + +/** + * v1.23 — step-up gate. Passes only for a COOKIE session whose + * `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user has an + * active second factor (`totpConfirmedAt`). Throws `StepUpRequiredError` + * (401, `errorCode: "auth.stepup.required"`) otherwise. + * + * Bearer tokens can NEVER satisfy this — exactly like `requireAdmin`, the + * resolution path is `getSession()` (cookie-only) and there is no Bearer + * fall-through. A token transport carries no `mfaVerifiedAt` and cannot + * acquire one, so the boundary is structural, not a softenable runtime check. + * + * Consumed in Phase M by MFA disable + recovery-code regeneration; later + * waves gate account deletion, key rotation, and passphrase export on it. + */ +export async function requireFreshMfa( + maxAgeSeconds: number, +): Promise { + const sessionData = await getSession(); + if (!sessionData) throw new HttpError(401, "Not authenticated"); + + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + + // The user must actually have a second factor active. A single-factor + // account cannot produce a fresh-MFA proof, so step-up-gated actions are + // unreachable for it by design (the management UI gates enrolment first). + if (!sessionData.user.totpConfirmedAt) { + throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); + } + + // Read the freshness stamp off the live session row — `getSession`'s + // projection intentionally omits it. + const row = await prisma.session.findUnique({ + where: { id: sessionData.session.id }, + select: { mfaVerifiedAt: true }, + }); + const verifiedAt = row?.mfaVerifiedAt ?? null; + if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) { + throw new StepUpRequiredError(); + } + + return { ...sessionData, mfaVerifiedAt: verifiedAt }; +} + /** * Report unhandled errors to GlitchTip (fire-and-forget). * Uses dynamic import to avoid circular dependencies and startup cost. diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 34de657f2..7a2c73e77 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -58,6 +58,11 @@ export async function createSession( onboardingPending: boolean, ipAddress?: string | null, userAgent?: string | null, + // v1.23 — set when this session was minted behind a completed second + // factor (the `/api/auth/mfa/verify` path) or a fresh factor re-confirm. + // `requireFreshMfa` reads it for step-up freshness; a null value means + // "single-factor session" and never satisfies step-up. + mfaVerifiedAt?: Date | null, ): Promise { const session = await prisma.session.create({ data: { @@ -65,6 +70,7 @@ export async function createSession( expiresAt: new Date(Date.now() + SESSION_MAX_AGE_MS), ipAddress: ipAddress ?? null, userAgent: userAgent ?? null, + mfaVerifiedAt: mfaVerifiedAt ?? null, }, }); From b446a6675373a51bb5c766bf80d63861dce62b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 19:56:16 +0200 Subject: [PATCH 04/34] feat(auth): add TOTP second factor and recovery codes Add the TOTP enroll/confirm/disable surface and Argon2id-hashed recovery codes as the v1.23 second factor. - totp.ts wraps otpauth (zero-dep) pinned to the interop floor (SHA-1, 6 digits, 30 s) with a +/-1 drift window. Replay protection persists the last accepted time-step per account (User.totpLastStep, migration 0202_v123_totp_laststep) and rejects any step that is not strictly newer, so a captured code dies the instant it is accepted once. - recovery-codes.ts issues ten single-use, high-entropy codes, Argon2id- hashed at rest, shown once; regeneration replaces the entire prior set in a transaction and verification burns the matched code with a guarded update. - The TOTP secret is stored AES-256-GCM encrypted (already registered in the encrypted-columns registry + rotation script); decrypt is fail-closed. - Routes: setup (pending secret), confirm (activate + issue recovery codes), disable (step-up + current factor), recovery-codes/regenerate (step-up). Setup/confirm are cookie-only; disable/regenerate add requireFreshMfa. - scripts/disable-mfa.ts is the operator escape hatch for a locked-out self-hoster, audited, run via pnpm dlx tsx. --- package.json | 1 + pnpm-lock.yaml | 16 +++ .../0202_v123_totp_laststep/migration.sql | 11 ++ prisma/schema.prisma | 6 + scripts/disable-mfa.ts | 90 ++++++++++++ src/app/api/auth/me/mfa/disable/route.ts | 100 +++++++++++++ .../me/mfa/recovery-codes/regenerate/route.ts | 60 ++++++++ src/app/api/auth/me/mfa/totp/confirm/route.ts | 124 ++++++++++++++++ src/app/api/auth/me/mfa/totp/setup/route.ts | 76 ++++++++++ .../auth/mfa/__tests__/recovery-codes.test.ts | 112 +++++++++++++++ src/lib/auth/mfa/__tests__/totp.test.ts | 100 +++++++++++++ src/lib/auth/mfa/recovery-codes.ts | 133 ++++++++++++++++++ src/lib/auth/mfa/totp.ts | 131 +++++++++++++++++ src/lib/auth/mfa/verify-factor.ts | 76 ++++++++++ src/lib/validations/mfa.ts | 50 +++++++ 15 files changed, 1086 insertions(+) create mode 100644 prisma/migrations/0202_v123_totp_laststep/migration.sql create mode 100644 scripts/disable-mfa.ts create mode 100644 src/app/api/auth/me/mfa/disable/route.ts create mode 100644 src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts create mode 100644 src/app/api/auth/me/mfa/totp/confirm/route.ts create mode 100644 src/app/api/auth/me/mfa/totp/setup/route.ts create mode 100644 src/lib/auth/mfa/__tests__/recovery-codes.test.ts create mode 100644 src/lib/auth/mfa/__tests__/totp.test.ts create mode 100644 src/lib/auth/mfa/recovery-codes.ts create mode 100644 src/lib/auth/mfa/totp.ts create mode 100644 src/lib/auth/mfa/verify-factor.ts create mode 100644 src/lib/validations/mfa.ts diff --git a/package.json b/package.json index 29b6f8321..1474c7989 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "next": "16.2.9", "next-themes": "^0.4.6", "nodemailer": "^9.0.1", + "otpauth": "^9.5.1", "p-limit": "^7.3.0", "pg-boss": "^12.20.0", "prisma": "^7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 525664542..8d9e539c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: nodemailer: specifier: ^9.0.1 version: 9.0.1 + otpauth: + specifier: ^9.5.1 + version: 9.5.1 p-limit: specifier: ^7.3.0 version: 7.3.0 @@ -1290,6 +1293,10 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} @@ -5396,6 +5403,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + otpauth@9.5.1: + resolution: {integrity: sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -7978,6 +7988,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} + '@nodable/entities@2.1.0': {} '@node-rs/argon2-android-arm-eabi@2.0.2': @@ -12157,6 +12169,10 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + otpauth@9.5.1: + dependencies: + '@noble/hashes': 2.2.0 + outvariant@1.4.3: {} own-keys@1.0.1: diff --git a/prisma/migrations/0202_v123_totp_laststep/migration.sql b/prisma/migrations/0202_v123_totp_laststep/migration.sql new file mode 100644 index 000000000..a4d5aa577 --- /dev/null +++ b/prisma/migrations/0202_v123_totp_laststep/migration.sql @@ -0,0 +1,11 @@ +-- v1.23 — TOTP replay guard. +-- +-- Persists the last accepted time-step counter per account so a verified +-- code cannot be replayed within its 30-second validity window. The verify +-- path resolves the submitted code to its RFC 6238 step (`floor(now / +-- period)` adjusted by the ±1 drift delta) and rejects any step that is not +-- strictly greater than this column. Additive, nullable — existing rows +-- (no TOTP enrolled) need no backfill, and a freshly confirmed factor starts +-- with NULL (the first verify simply records its step). +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "totp_last_step" BIGINT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9aaf0ae5b..bf517cb88 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -556,6 +556,12 @@ model User { // `passkeyUpgradeNudgeDismissed` are per-account flags later waves read. totpSecretEncrypted String? @map("totp_secret_encrypted") totpConfirmedAt DateTime? @map("totp_confirmed_at") + // v1.23 — TOTP replay guard. The last accepted time-step counter + // (RFC 6238 `T = floor(now / period)`). A code is rejected when its + // resolved step is <= this value even while still inside the ±1 drift + // window, so a captured code cannot be reused within its 30-second + // life. BigInt because the step counter grows unbounded with wall time. + totpLastStep BigInt? @map("totp_last_step") mfaEnforced Boolean @default(false) @map("mfa_enforced") passkeyUpgradeNudgeDismissed Boolean @default(false) @map("passkey_upgrade_nudge_dismissed") diff --git a/scripts/disable-mfa.ts b/scripts/disable-mfa.ts new file mode 100644 index 000000000..456ccae76 --- /dev/null +++ b/scripts/disable-mfa.ts @@ -0,0 +1,90 @@ +/** + * scripts/disable-mfa.ts + * + * Operator escape hatch: clear the second factor for an account whose owner + * has locked themselves out (lost authenticator AND recovery codes). Mirrors + * the password-reset CLI — it runs out-of-band against the database, so it is + * the recovery path of last resort for a self-hoster. + * + * It clears the TOTP secret, the confirmed-at stamp and the replay counter, + * and deletes every recovery code, then writes an `auth.mfa.disabled` audit + * row (with `details.source = "cli"`) so the action is traceable. + * + * Run via the bundled tsx (the production standalone image strips tsx): + * pnpm dlx tsx scripts/disable-mfa.ts user@example.com + */ +import "dotenv/config"; +import { PrismaClient } from "@/generated/prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error("DATABASE_URL must be set"); + process.exit(1); +} + +const identifier = process.argv[2]; +if (!identifier) { + console.error( + "Usage: pnpm dlx tsx scripts/disable-mfa.ts ", + ); + process.exit(2); +} + +const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: DATABASE_URL }), +}); + +async function main(): Promise { + const user = await prisma.user.findFirst({ + where: { + OR: [ + { email: { equals: identifier, mode: "insensitive" } }, + { username: { equals: identifier, mode: "insensitive" } }, + ], + }, + select: { id: true, username: true, totpConfirmedAt: true }, + }); + + if (!user) { + console.error(`No account matches '${identifier}'.`); + process.exit(3); + } + + if (!user.totpConfirmedAt) { + console.log( + `Account '${user.username}' has no active second factor — nothing to do.`, + ); + return; + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: user.id }, + data: { + totpSecretEncrypted: null, + totpConfirmedAt: null, + totpLastStep: null, + }, + }); + await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } }); + await tx.auditLog.create({ + data: { + action: "auth.mfa.disabled", + userId: user.id, + details: JSON.stringify({ source: "cli", factor: "totp" }), + }, + }); + }); + + console.log(`Second factor cleared for account '${user.username}'.`); +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(() => { + void prisma.$disconnect(); + }); diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts new file mode 100644 index 000000000..24a266288 --- /dev/null +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -0,0 +1,100 @@ +/** + * POST /api/auth/me/mfa/disable + * + * Tear down the second factor. Double-gated: + * 1. `requireFreshMfa` — a cookie session that completed a second factor + * within the step-up window (Bearer can never satisfy this). + * 2. A current TOTP or recovery code in the body — proves live possession at + * the moment of the destructive action, not just a stale fresh-session. + * + * On success it clears `totpSecretEncrypted` / `totpConfirmedAt` / + * `totpLastStep` and deletes every recovery code in one transaction. + */ +import { + apiHandler, + requireFreshMfa, + HttpError, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { mfaDisableSchema } from "@/lib/validations/mfa"; + +export const dynamic = "force-dynamic"; + +const DISABLE_RATE_LIMIT = 5; +const DISABLE_WINDOW_MS = 15 * 60 * 1000; + +export const POST = apiHandler(async (req: Request) => { + // Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the + // session is not freshly second-factor-verified. + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + + const rl = await checkRateLimit( + `mfa:disable:${user.id}`, + DISABLE_RATE_LIMIT, + DISABLE_WINDOW_MS, + ); + if (!rl.allowed) { + const res = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + res.headers.set(k, v); + } + return res; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(400, "Invalid JSON body"); + } + const parsed = mfaDisableSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + + const factor = await verifyMfaFactor( + user, + parsed.data.method, + parsed.data.code, + ); + if (!factor.ok) { + await auditLog("auth.mfa.failed", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + stage: "disable", + method: parsed.data.method, + replay: factor.replay, + }, + }); + annotate({ action: { name: "auth.mfa.disable.invalid_factor" } }); + throw new HttpError(401, "Invalid code"); + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: user.id }, + data: { + totpSecretEncrypted: null, + totpConfirmedAt: null, + totpLastStep: null, + }, + }); + await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } }); + }); + + await auditLog("auth.mfa.disabled", { + userId: user.id, + ipAddress: getClientIp(req), + details: { factor: parsed.data.method }, + }); + annotate({ action: { name: "auth.mfa.disabled" } }); + + return apiSuccess({ enabled: false }); +}); diff --git a/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts b/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts new file mode 100644 index 000000000..a82e3d87d --- /dev/null +++ b/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts @@ -0,0 +1,60 @@ +/** + * POST /api/auth/me/mfa/recovery-codes/regenerate + * + * Issue a fresh recovery-code batch, invalidating the entire prior set in one + * transaction. Step-up gated (`requireFreshMfa`) — a recovery-code rotation is + * a sensitive action; Bearer can never satisfy the gate. The new codes are + * returned **once**. + */ +import { + apiHandler, + requireFreshMfa, + HttpError, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { regenerateRecoveryCodes } from "@/lib/auth/mfa/recovery-codes"; + +export const dynamic = "force-dynamic"; + +const REGEN_RATE_LIMIT = 5; +const REGEN_WINDOW_MS = 15 * 60 * 1000; + +export const POST = apiHandler(async (req: Request) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + + // Defence in depth — regeneration only makes sense for an active factor. + if (!user.totpConfirmedAt) { + throw new HttpError(409, "No second factor is active"); + } + + const rl = await checkRateLimit( + `mfa:recovery-regen:${user.id}`, + REGEN_RATE_LIMIT, + REGEN_WINDOW_MS, + ); + if (!rl.allowed) { + const res = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + res.headers.set(k, v); + } + return res; + } + + const recoveryCodes = await regenerateRecoveryCodes(user.id); + + await auditLog("auth.mfa.recovery_regenerated", { + userId: user.id, + ipAddress: getClientIp(req), + details: { count: recoveryCodes.length }, + }); + annotate({ action: { name: "auth.mfa.recovery_regenerated" } }); + + return apiSuccess({ + recoveryCodes, + recoveryCodesRemaining: recoveryCodes.length, + }); +}); diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts new file mode 100644 index 000000000..861c7c7a3 --- /dev/null +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -0,0 +1,124 @@ +/** + * POST /api/auth/me/mfa/totp/confirm + * + * Finish TOTP enrollment. Verifies a code against the pending secret; on + * success it promotes pending → active (`totpConfirmedAt = now()`), records + * the accepted time-step (replay guard), generates + persists the Argon2id- + * hashed recovery codes, and returns the codes **once** for the user to save. + * + * The completing session is also stamped `mfaVerifiedAt = now()` so the user + * can immediately manage the freshly-enabled factor (disable / regenerate + * codes) without an extra step-up round-trip. + * + * Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. + */ +import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { verifyTotp } from "@/lib/auth/mfa/totp"; +import { + generateRecoveryCodes, + persistRecoveryCodes, +} from "@/lib/auth/mfa/recovery-codes"; +import { totpConfirmSchema } from "@/lib/validations/mfa"; + +export const dynamic = "force-dynamic"; + +const CONFIRM_RATE_LIMIT = 10; +const CONFIRM_WINDOW_MS = 15 * 60 * 1000; + +export const POST = apiHandler(async (req: Request) => { + const { user, session } = await requireCookieAuth(); + + const rl = await checkRateLimit( + `mfa:confirm:${user.id}`, + CONFIRM_RATE_LIMIT, + CONFIRM_WINDOW_MS, + ); + if (!rl.allowed) { + const res = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + res.headers.set(k, v); + } + return res; + } + + if (user.totpConfirmedAt) { + throw new HttpError(409, "A second factor is already active"); + } + if (!user.totpSecretEncrypted) { + throw new HttpError(409, "Start enrollment before confirming"); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(400, "Invalid JSON body"); + } + const parsed = totpConfirmSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid code", 422); + } + + // Decrypt is fail-closed — a missing/rotated key throws rather than + // confirming against junk. + const secret = decrypt(user.totpSecretEncrypted); + const result = verifyTotp( + secret, + parsed.data.code, + user.totpLastStep === null ? null : Number(user.totpLastStep), + ); + + if (!result.valid) { + await auditLog("auth.mfa.failed", { + userId: user.id, + ipAddress: getClientIp(req), + details: { stage: "confirm", replay: result.replay }, + }); + annotate({ + action: { name: "auth.mfa.totp.confirm.invalid" }, + meta: { replay: result.replay }, + }); + throw new HttpError(401, "Invalid code"); + } + + const recoveryCodes = generateRecoveryCodes(); + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: user.id }, + data: { + totpConfirmedAt: new Date(), + totpLastStep: BigInt(result.step as number), + }, + }); + // A fresh enrollment starts from a clean recovery set. + await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } }); + await persistRecoveryCodes(tx, user.id, recoveryCodes); + // Stamp the completing session so the user can immediately manage the + // factor under step-up without re-verifying. + await tx.session.updateMany({ + where: { id: session.id }, + data: { mfaVerifiedAt: new Date() }, + }); + }); + + await auditLog("auth.mfa.enabled", { + userId: user.id, + ipAddress: getClientIp(req), + details: { factor: "totp" }, + }); + annotate({ action: { name: "auth.mfa.enabled" } }); + + // `recoveryCodes` is redaction-denylisted (`/recovery.?code/i`). + return apiSuccess({ + enabled: true, + recoveryCodes, + recoveryCodesRemaining: recoveryCodes.length, + }); +}); diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts new file mode 100644 index 000000000..57fa885d5 --- /dev/null +++ b/src/app/api/auth/me/mfa/totp/setup/route.ts @@ -0,0 +1,76 @@ +/** + * POST /api/auth/me/mfa/totp/setup + * + * Begin TOTP enrollment. Generates a 160-bit secret, stores it **encrypted** + * (AES-256-GCM, the same at-rest envelope as every other credential), and + * returns the `otpauth://` URI + the raw Base32 secret so the client can + * render the QR and offer manual entry. The secret is **pending** — MFA is + * not active until `/confirm` verifies a code, so `totpConfirmedAt` stays + * null here. + * + * Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. The + * recovery-code batch is issued at `/confirm` (after the factor is proven), + * not here, so an abandoned setup never persists codes. + */ +import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { encrypt } from "@/lib/crypto"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp"; + +export const dynamic = "force-dynamic"; + +const SETUP_RATE_LIMIT = 5; +const SETUP_WINDOW_MS = 15 * 60 * 1000; + +export const POST = apiHandler(async (req: Request) => { + const { user } = await requireCookieAuth(); + + const rl = await checkRateLimit( + `mfa:setup:${user.id}`, + SETUP_RATE_LIMIT, + SETUP_WINDOW_MS, + ); + if (!rl.allowed) { + const res = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + res.headers.set(k, v); + } + return res; + } + + // An already-active factor must be disabled (step-up gated) before a new + // secret can be enrolled — re-running setup must not silently rotate the + // live secret out from under the user's authenticator. + if (user.totpConfirmedAt) { + annotate({ action: { name: "auth.mfa.totp.setup.already_active" } }); + throw new HttpError(409, "A second factor is already active"); + } + + const secret = generateTotpSecret(); + const account = user.email ?? user.username; + const otpauthUri = buildOtpauthUri(secret, account); + + // Store the pending secret encrypted; leave `totpConfirmedAt` null. + await prisma.user.update({ + where: { id: user.id }, + data: { + totpSecretEncrypted: encrypt(secret), + // A re-setup resets the replay counter — the new secret starts fresh. + totpLastStep: null, + }, + }); + + await auditLog("auth.mfa.totp.setup", { + userId: user.id, + ipAddress: getClientIp(req), + }); + annotate({ action: { name: "auth.mfa.totp.setup" } }); + + // `totpSecret` / `otpauthUri` are redaction-denylisted (`/totp/i`, + // `/otp/i`, `/secret/i`) so they never surface in a wide-event excerpt. + return apiSuccess({ otpauthUri, totpSecret: secret }); +}); diff --git a/src/lib/auth/mfa/__tests__/recovery-codes.test.ts b/src/lib/auth/mfa/__tests__/recovery-codes.test.ts new file mode 100644 index 000000000..ed4b0cc4b --- /dev/null +++ b/src/lib/auth/mfa/__tests__/recovery-codes.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Real Argon2id hashing (a handful of codes is fast enough for a unit test) +// so the hash/verify round-trip is exercised end-to-end. Only the DB is mocked. +vi.mock("@/lib/db", () => ({ + prisma: { + mfaRecoveryCode: { + createMany: vi.fn(), + deleteMany: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + count: vi.fn(), + }, + $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => { + // The transactional helper receives the same mocked client. + const { prisma } = await import("@/lib/db"); + return fn(prisma); + }), + }, +})); + +import { + generateRecoveryCodes, + regenerateRecoveryCodes, + verifyAndConsumeRecoveryCode, + normaliseRecoveryCode, + RECOVERY_CODE_COUNT, +} from "../recovery-codes"; +import { hashPassword } from "@/lib/auth/password"; +import { prisma } from "@/lib/db"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("recovery-codes", () => { + it("generates the configured count of high-entropy, grouped codes", () => { + const codes = generateRecoveryCodes(); + expect(codes).toHaveLength(RECOVERY_CODE_COUNT); + for (const c of codes) { + expect(c).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}$/); + } + // No duplicates in a batch. + expect(new Set(codes).size).toBe(codes.length); + }); + + it("verifies a code once then refuses it (one-time use)", async () => { + const code = "ABCDE-FGHJK"; + const codeHash = await hashPassword(normaliseRecoveryCode(code)); + + vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([ + { id: "row-1", codeHash }, + ] as never); + // First consume succeeds — the guarded update flips exactly one row. + vi.mocked(prisma.mfaRecoveryCode.updateMany).mockResolvedValueOnce({ + count: 1, + } as never); + + const first = await verifyAndConsumeRecoveryCode("user-1", code); + expect(first).toBe(true); + + // Second attempt: the row is now used, so findMany returns nothing. + vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([] as never); + const second = await verifyAndConsumeRecoveryCode("user-1", code); + expect(second).toBe(false); + }); + + it("treats a lost claim race (count !== 1) as a miss", async () => { + const code = "ABCDE-FGHJK"; + const codeHash = await hashPassword(normaliseRecoveryCode(code)); + vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([ + { id: "row-1", codeHash }, + ] as never); + vi.mocked(prisma.mfaRecoveryCode.updateMany).mockResolvedValueOnce({ + count: 0, + } as never); + + expect(await verifyAndConsumeRecoveryCode("user-1", code)).toBe(false); + }); + + it("rejects a code that matches no stored hash", async () => { + const realHash = await hashPassword(normaliseRecoveryCode("AAAAA-BBBBB")); + vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([ + { id: "row-1", codeHash: realHash }, + ] as never); + expect(await verifyAndConsumeRecoveryCode("user-1", "ZZZZZ-99999")).toBe( + false, + ); + expect(prisma.mfaRecoveryCode.updateMany).not.toHaveBeenCalled(); + }); + + it("regeneration deletes the entire prior set before inserting", async () => { + vi.mocked(prisma.mfaRecoveryCode.deleteMany).mockResolvedValue({ + count: 10, + } as never); + vi.mocked(prisma.mfaRecoveryCode.createMany).mockResolvedValue({ + count: 10, + } as never); + + const codes = await regenerateRecoveryCodes("user-1"); + expect(codes).toHaveLength(RECOVERY_CODE_COUNT); + expect(prisma.mfaRecoveryCode.deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + }); + // Delete is ordered before the insert. + const deleteOrder = vi.mocked(prisma.mfaRecoveryCode.deleteMany).mock + .invocationCallOrder[0]; + const insertOrder = vi.mocked(prisma.mfaRecoveryCode.createMany).mock + .invocationCallOrder[0]; + expect(deleteOrder).toBeLessThan(insertOrder); + }); +}); diff --git a/src/lib/auth/mfa/__tests__/totp.test.ts b/src/lib/auth/mfa/__tests__/totp.test.ts new file mode 100644 index 000000000..ae9e73dbf --- /dev/null +++ b/src/lib/auth/mfa/__tests__/totp.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import * as OTPAuth from "otpauth"; +import { + generateTotpSecret, + buildOtpauthUri, + verifyTotp, + currentTotpStep, + TOTP_PERIOD_SECONDS, +} from "../totp"; + +/** Compute the canonical code a compliant authenticator would show at `atMs`. */ +function codeAt(secretBase32: string, atMs: number): string { + const totp = new OTPAuth.TOTP({ + issuer: "HealthLog", + label: "HealthLog", + algorithm: "SHA1", + digits: 6, + period: TOTP_PERIOD_SECONDS, + secret: OTPAuth.Secret.fromBase32(secretBase32), + }); + return totp.generate({ timestamp: atMs }); +} + +const PERIOD_MS = TOTP_PERIOD_SECONDS * 1000; + +describe("totp", () => { + it("generates a Base32 secret and a parseable otpauth URI", () => { + const secret = generateTotpSecret(); + expect(secret).toMatch(/^[A-Z2-7]+$/); + const uri = buildOtpauthUri(secret, "user@example.com"); + expect(uri.startsWith("otpauth://totp/")).toBe(true); + expect(uri).toContain("issuer=HealthLog"); + expect(uri).toContain("algorithm=SHA1"); + expect(uri).toContain("digits=6"); + // Round-trips back to the same secret. + const parsed = OTPAuth.URI.parse(uri) as OTPAuth.TOTP; + expect(parsed.secret.base32).toBe(secret); + }); + + it("accepts the current code and reports its step", () => { + const secret = generateTotpSecret(); + const now = 1_700_000_000_000; + const res = verifyTotp(secret, codeAt(secret, now), null, now); + expect(res.valid).toBe(true); + expect(res.replay).toBe(false); + expect(res.step).toBe(currentTotpStep(now)); + }); + + it("rejects a wrong code and a non-numeric code", () => { + const secret = generateTotpSecret(); + const now = 1_700_000_000_000; + expect(verifyTotp(secret, "000000", null, now).valid).toBe(false); + expect(verifyTotp(secret, "abcdef", null, now).valid).toBe(false); + expect(verifyTotp(secret, "12345", null, now).valid).toBe(false); // too short + }); + + it("accepts the previous and next step within the ±1 drift window", () => { + const secret = generateTotpSecret(); + const now = 1_700_000_400_000; + const prev = codeAt(secret, now - PERIOD_MS); + const next = codeAt(secret, now + PERIOD_MS); + expect(verifyTotp(secret, prev, null, now).valid).toBe(true); + expect(verifyTotp(secret, next, null, now).valid).toBe(true); + }); + + it("rejects a code two steps away (outside the window)", () => { + const secret = generateTotpSecret(); + const now = 1_700_000_400_000; + const twoBack = codeAt(secret, now - 2 * PERIOD_MS); + expect(verifyTotp(secret, twoBack, null, now).valid).toBe(false); + }); + + it("rejects replay of an already-accepted step (monotonic guard)", () => { + const secret = generateTotpSecret(); + const now = 1_700_000_000_000; + const code = codeAt(secret, now); + const first = verifyTotp(secret, code, null, now); + expect(first.valid).toBe(true); + const step = first.step as number; + + // Same code, now with lastStep recorded — must be rejected as a replay + // even though it is still inside its 30-second life. + const second = verifyTotp(secret, code, step, now); + expect(second.valid).toBe(false); + expect(second.replay).toBe(true); + expect(second.step).toBe(step); + }); + + it("rejects a still-valid older step once a newer one was accepted", () => { + const secret = generateTotpSecret(); + const now = 1_700_000_400_000; + const prevCode = codeAt(secret, now - PERIOD_MS); + const prevStep = currentTotpStep(now) - 1; + // The previous-step code is in-window but its step <= lastStep (current). + const res = verifyTotp(secret, prevCode, currentTotpStep(now), now); + expect(res.valid).toBe(false); + expect(res.replay).toBe(true); + expect(res.step).toBe(prevStep); + }); +}); diff --git a/src/lib/auth/mfa/recovery-codes.ts b/src/lib/auth/mfa/recovery-codes.ts new file mode 100644 index 000000000..6e9caa54e --- /dev/null +++ b/src/lib/auth/mfa/recovery-codes.ts @@ -0,0 +1,133 @@ +/** + * Recovery (backup) codes — the lost-authenticator fallback for the TOTP + * second factor. + * + * - **10 single-use codes**, each ~64+ bits of CSPRNG entropy, rendered as + * two Base32 groups for legibility (`XXXXX-XXXXX`). + * - **Argon2id-hashed at rest** (the same primitive and cost parameters as + * the account password) — OWASP cautions that recovery-code keyspaces are + * small, so a slow hash is what makes the at-rest storage meaningful. + * - **Shown once** at TOTP confirm (and on explicit regeneration); only the + * hash is stored, so the plaintext set can never be re-derived server-side. + * - **One-time use:** verifying a code burns it (`usedAt`). + * - **Regeneration invalidates the entire prior set** in a transaction. + * + * Verification is constant-time-ish: the candidate is Argon2id-verified + * against every still-unused hash for the user (Argon2's verify is itself + * constant-time), so there is no early-exit oracle on which code matched. + */ +import { randomInt } from "node:crypto"; +import { hashPassword, verifyPassword } from "@/lib/auth/password"; +import { prisma } from "@/lib/db"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +export const RECOVERY_CODE_COUNT = 10; +/** Crockford-ish Base32 alphabet minus easily-confused glyphs (0/O, 1/I/L). */ +const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789"; +const GROUP_LENGTH = 5; +const GROUPS = 2; // 10 chars over a 29-symbol alphabet ≈ 49 bits/group pair → ~64+ bits set-wide guard below + +/** A single high-entropy code, e.g. `7QF3K-MZ2WP`. */ +function generateRecoveryCode(): string { + const groups: string[] = []; + for (let g = 0; g < GROUPS; g++) { + let group = ""; + for (let i = 0; i < GROUP_LENGTH; i++) { + group += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)]; + } + groups.push(group); + } + return groups.join("-"); +} + +/** Generate a fresh batch of plaintext codes (caller shows them once). */ +export function generateRecoveryCodes( + count: number = RECOVERY_CODE_COUNT, +): string[] { + return Array.from({ length: count }, generateRecoveryCode); +} + +/** Normalise user input: strip spaces, upper-case (the display is grouped). */ +export function normaliseRecoveryCode(input: string): string { + return input.trim().toUpperCase().replace(/\s+/g, ""); +} + +type TxClient = Prisma.TransactionClient | PrismaClient; + +/** + * Argon2id-hash a batch and persist one row per code. Runs inside the + * caller's transaction so the recovery set lands atomically with the + * pending→active promotion (confirm) or the prior-set deletion (regen). + */ +export async function persistRecoveryCodes( + tx: TxClient, + userId: string, + codes: string[], +): Promise { + const hashes = await Promise.all( + codes.map((code) => hashPassword(normaliseRecoveryCode(code))), + ); + await tx.mfaRecoveryCode.createMany({ + data: hashes.map((codeHash) => ({ userId, codeHash })), + }); +} + +/** + * Replace the user's entire recovery-code set with a fresh batch and return + * the new plaintext codes (shown once). The delete + insert run in one + * transaction so a regeneration never leaves a half-rotated set. + */ +export async function regenerateRecoveryCodes( + userId: string, + count: number = RECOVERY_CODE_COUNT, +): Promise { + const codes = generateRecoveryCodes(count); + await prisma.$transaction(async (tx) => { + await tx.mfaRecoveryCode.deleteMany({ where: { userId } }); + await persistRecoveryCodes(tx, userId, codes); + }); + return codes; +} + +/** Count the codes a user still has available (unused). */ +export async function countRemainingRecoveryCodes( + userId: string, +): Promise { + return prisma.mfaRecoveryCode.count({ where: { userId, usedAt: null } }); +} + +/** + * Verify a candidate against the user's unused codes and burn it on match. + * + * The burn is a guarded `updateMany` (`usedAt: null` in the WHERE) so two + * concurrent verifications of the same code can never both succeed — the + * second update affects zero rows and is treated as a miss. Returns true + * only when exactly one row transitioned to used. + */ +export async function verifyAndConsumeRecoveryCode( + userId: string, + candidate: string, +): Promise { + const normalised = normaliseRecoveryCode(candidate); + if (normalised.length === 0) return false; + + const rows = await prisma.mfaRecoveryCode.findMany({ + where: { userId, usedAt: null }, + select: { id: true, codeHash: true }, + }); + + for (const row of rows) { + // Argon2id verify is constant-time; iterate the full set so the match + // position is not observable through timing. + const match = await verifyPassword(row.codeHash, normalised); + if (!match) continue; + + const burned = await prisma.mfaRecoveryCode.updateMany({ + where: { id: row.id, usedAt: null }, + data: { usedAt: new Date() }, + }); + return burned.count === 1; + } + + return false; +} diff --git a/src/lib/auth/mfa/totp.ts b/src/lib/auth/mfa/totp.ts new file mode 100644 index 000000000..5170c29e5 --- /dev/null +++ b/src/lib/auth/mfa/totp.ts @@ -0,0 +1,131 @@ +/** + * TOTP (RFC 6238) second-factor primitives. + * + * Thin wrapper over `otpauth` (zero runtime deps) pinning the de-facto + * interop floor — **SHA-1, 6 digits, 30-second period** — because + * SHA-256/512 break the common authenticator apps and hardware tokens. + * Drift window is **±1 step** (previous / current / next), the RFC's + * recommended bound; every extra step widens the replay surface. + * + * Replay protection (the commonly-omitted control) is enforced by the + * caller via the monotonic last-step counter: `verifyTotp` resolves the + * submitted code to its absolute time-step and returns it, and the caller + * rejects any step that is not strictly greater than the persisted + * `User.totpLastStep`. A captured code therefore dies the instant it is + * accepted once, even while it is still inside its 30-second life. + * + * The shared secret never leaves this module as anything but Base32; the + * `otpauth://` URI carries the secret and is built here only to hand to + * the enrolling client — it is never logged or persisted. + */ +import * as OTPAuth from "otpauth"; + +export const TOTP_ISSUER = "HealthLog"; +export const TOTP_ALGORITHM = "SHA1"; +export const TOTP_DIGITS = 6; +export const TOTP_PERIOD_SECONDS = 30; +/** ±1 step (≈ ±30 s clock skew). Do not widen without cause. */ +export const TOTP_WINDOW = 1; +/** 160-bit secret per OWASP MFA / NIST 800-63B (`size` is bytes). */ +const TOTP_SECRET_BYTES = 20; + +/** + * Generate a fresh CSPRNG Base32 secret (160-bit). The returned string is + * the only form persisted (AES-256-GCM encrypted) and reconstituted for + * verification. + */ +export function generateTotpSecret(): string { + return new OTPAuth.Secret({ size: TOTP_SECRET_BYTES }).base32; +} + +/** + * Build the `otpauth://totp/...` enrollment URI for a Base32 secret. + * Issuer is the fixed app name; `account` is the user-facing label + * (email or username). The URI carries the secret — hand it to the client, + * never log or persist it. + */ +export function buildOtpauthUri(secretBase32: string, account: string): string { + const totp = new OTPAuth.TOTP({ + issuer: TOTP_ISSUER, + label: account, + algorithm: TOTP_ALGORITHM, + digits: TOTP_DIGITS, + period: TOTP_PERIOD_SECONDS, + secret: OTPAuth.Secret.fromBase32(secretBase32), + }); + return totp.toString(); +} + +/** The absolute RFC 6238 time-step for a moment in time. */ +export function currentTotpStep(atMs: number = Date.now()): number { + return Math.floor(atMs / 1000 / TOTP_PERIOD_SECONDS); +} + +export interface TotpVerifyResult { + /** True only when the code is in-window AND not a replay. */ + valid: boolean; + /** + * The absolute time-step the code resolved to (current step + drift + * delta). Present whenever the code matched the secret in-window — even + * when rejected as a replay — so the caller can log the reason. Persist + * this as the new `totpLastStep` on a successful, non-replay accept. + */ + step: number | null; + /** True when the code matched in-window but its step was already used. */ + replay: boolean; +} + +/** + * Verify a 6-digit code against a Base32 secret with ±1 drift and + * monotonic replay rejection. + * + * @param secretBase32 the user's stored TOTP secret + * @param code the submitted 6-digit code (non-digits → invalid) + * @param lastStep the user's persisted `totpLastStep` (null = none yet) + * @param atMs clock injection point for tests + */ +export function verifyTotp( + secretBase32: string, + code: string, + lastStep: number | null, + atMs: number = Date.now(), +): TotpVerifyResult { + const normalised = code.trim(); + if (!/^\d{6}$/.test(normalised)) { + return { valid: false, step: null, replay: false }; + } + + const totp = new OTPAuth.TOTP({ + issuer: TOTP_ISSUER, + label: TOTP_ISSUER, + algorithm: TOTP_ALGORITHM, + digits: TOTP_DIGITS, + period: TOTP_PERIOD_SECONDS, + secret: OTPAuth.Secret.fromBase32(secretBase32), + }); + + // `validate` returns the signed step delta (e.g. -1, 0, +1) when the + // code matches inside the window, or null when it does not. The compare + // is over the numeric HOTP truncation, not a string equality on the + // secret, so there is no early-exit timing oracle on the secret bytes. + const delta = totp.validate({ + token: normalised, + timestamp: atMs, + window: TOTP_WINDOW, + }); + + if (delta === null) { + return { valid: false, step: null, replay: false }; + } + + const step = currentTotpStep(atMs) + delta; + + // Monotonic replay guard: the resolved step must be strictly newer than + // the last one we accepted. Reusing an already-burned step — even one + // still inside its 30-second life — is rejected. + if (lastStep !== null && step <= lastStep) { + return { valid: false, step, replay: true }; + } + + return { valid: true, step, replay: false }; +} diff --git a/src/lib/auth/mfa/verify-factor.ts b/src/lib/auth/mfa/verify-factor.ts new file mode 100644 index 000000000..7344787e5 --- /dev/null +++ b/src/lib/auth/mfa/verify-factor.ts @@ -0,0 +1,76 @@ +/** + * Verify a presented second factor (TOTP or recovery code) for a user, with + * the side effects that make each factor single-use: + * + * - **TOTP:** decrypt the stored secret (fail-closed), validate with ±1 drift + * and monotonic replay rejection, and on success **persist the accepted + * time-step** so the same code cannot be replayed within its 30-second life. + * - **Recovery code:** Argon2id-verify against the unused set and burn the + * matched row. + * + * Used by both the login-completion route (`/api/auth/mfa/verify`) and the + * step-up-gated MFA-disable route, so the factor semantics are identical + * wherever a current factor must be proven. + */ +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; +import { verifyTotp } from "@/lib/auth/mfa/totp"; +import { verifyAndConsumeRecoveryCode } from "@/lib/auth/mfa/recovery-codes"; + +export type MfaMethod = "totp" | "recovery"; + +export interface FactorUser { + id: string; + totpSecretEncrypted: string | null; + totpLastStep: bigint | null; +} + +export interface FactorResult { + ok: boolean; + /** TOTP only: the code matched the secret but its step was already used. */ + replay: boolean; +} + +export async function verifyMfaFactor( + user: FactorUser, + method: MfaMethod, + code: string, +): Promise { + if (method === "recovery") { + const ok = await verifyAndConsumeRecoveryCode(user.id, code); + return { ok, replay: false }; + } + + // TOTP. + if (!user.totpSecretEncrypted) return { ok: false, replay: false }; + const secret = decrypt(user.totpSecretEncrypted); + const result = verifyTotp( + secret, + code, + user.totpLastStep === null ? null : Number(user.totpLastStep), + ); + if (!result.valid) { + return { ok: false, replay: result.replay }; + } + + // Burn the step: persist it as the new floor so a replay of this exact + // code (still inside its 30-second window) is rejected. The guarded WHERE + // (`totpLastStep` unchanged) makes two concurrent accepts of the same step + // race-safe — only the first transitions the counter. + const advanced = await prisma.user.updateMany({ + where: { + id: user.id, + OR: [ + { totpLastStep: null }, + { totpLastStep: { lt: BigInt(result.step as number) } }, + ], + }, + data: { totpLastStep: BigInt(result.step as number) }, + }); + if (advanced.count !== 1) { + // Another request already consumed this (or a newer) step — treat as a + // replay rather than a fresh accept. + return { ok: false, replay: true }; + } + return { ok: true, replay: false }; +} diff --git a/src/lib/validations/mfa.ts b/src/lib/validations/mfa.ts new file mode 100644 index 000000000..c4a4a1a04 --- /dev/null +++ b/src/lib/validations/mfa.ts @@ -0,0 +1,50 @@ +/** + * Request schemas for the second-factor (MFA) surface. Single-sourced here so + * the runtime `safeParse` and the OpenAPI registry (`src/lib/openapi/routes/ + * auth.ts`) describe the identical wire contract. + */ +import { z } from "zod/v4"; + +/** A 6-digit TOTP code. */ +export const totpCodeSchema = z + .string() + .trim() + .regex(/^\d{6}$/, "Enter the 6-digit code from your authenticator app"); + +/** + * A recovery code as displayed (`XXXXX-XXXXX`) — accepted case-insensitively + * with or without the separator; the server normalises before comparing. + */ +export const recoveryCodeSchema = z.string().trim().min(8).max(32); + +export const totpConfirmSchema = z.object({ + code: totpCodeSchema, +}); + +/** Disable MFA: prove possession of a current factor (TOTP or recovery code). */ +export const mfaDisableSchema = z + .object({ + code: z.string().trim().min(6).max(32), + method: z.enum(["totp", "recovery"]).default("totp"), + }) + .meta({ + id: "MfaDisableRequest", + description: + "Disable the second factor. Requires fresh step-up plus a current TOTP or recovery code.", + }); + +/** Login second-factor completion: ticket + a TOTP or recovery code. */ +export const mfaVerifySchema = z + .object({ + mfaTicket: z + .string() + .min(1) + .describe("The opaque single-use ticket from the login response."), + method: z.enum(["totp", "recovery"]).default("totp"), + code: z.string().trim().min(6).max(32), + }) + .meta({ + id: "MfaVerifyRequest", + description: + "Complete a second-factor login challenge and receive the same token bundle the password path issues.", + }); From 6b3d3fd840cf6627a79e21c22d29df1ed2fbbe58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 19:56:30 +0200 Subject: [PATCH 05/34] feat(auth): branch login through a second-factor challenge When a password is accepted for an account with a confirmed TOTP factor, the login route no longer mints a session or token. It creates a single-use, ~5-minute MfaChallenge (the opaque ticket is stored only as a hash) and returns HTTP 200 with data:null, error:null and meta.mfaRequired + mfaTicket + methods. The client completes the login at POST /api/auth/mfa/verify with the ticket + a TOTP or recovery code, receiving the exact same token bundle the password path issues, with the session stamped second-factor-verified. - The ticket is claimed once, atomically, only after the factor verifies, so two concurrent submissions can mint at most one session; no session ever exists before factor two. - Wrong codes increment the ticket attempt counter and burn it at the cap (throttle, not an account lock); the anonymous verify surface is also rate-limited per IP. - meta.mfaRequired is returned only after a valid password and the invalid-credentials response is identical regardless of MFA state, so an attacker without the password cannot enumerate MFA. - The session/token tail is extracted to login-response.ts and shared by both the password path and the verify path, keeping the transport contract identical. OpenAPI registers the verify endpoint, the MFA management endpoints, and the mfaRequired login variant. --- docs/api/openapi.yaml | 325 +++++++++++++++++- .../api/auth/login/mfa-branch.test.ts | 131 +++++++ src/__tests__/api/auth/mfa/verify.test.ts | 156 +++++++++ src/app/api/auth/login/route.ts | 124 +++---- src/app/api/auth/mfa/verify/route.ts | 139 ++++++++ src/lib/auth/login-response.ts | 125 +++++++ src/lib/auth/mfa/__tests__/challenge.test.ts | 130 +++++++ src/lib/auth/mfa/challenge.ts | 133 +++++++ src/lib/openapi/routes/auth.ts | 181 +++++++++- tests/integration/mfa-login-flow.test.ts | 155 +++++++++ 10 files changed, 1508 insertions(+), 91 deletions(-) create mode 100644 src/__tests__/api/auth/login/mfa-branch.test.ts create mode 100644 src/__tests__/api/auth/mfa/verify.test.ts create mode 100644 src/app/api/auth/mfa/verify/route.ts create mode 100644 src/lib/auth/login-response.ts create mode 100644 src/lib/auth/mfa/__tests__/challenge.test.ts create mode 100644 src/lib/auth/mfa/challenge.ts create mode 100644 tests/integration/mfa-login-flow.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 79e597122..4055a7446 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1354,8 +1354,15 @@ paths: tags: - Auth summary: Email-or-username login (password) - description: "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA - prefix) additionally receive a paired access + refresh token." + description: >- + Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) + additionally receive a paired access + refresh token. + + + v1.23 — when the account has a confirmed second factor, the response carries no session/token. It returns HTTP + 200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the + `meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token + bundle. Accounts without MFA are unchanged. requestBody: required: true content: @@ -1374,11 +1381,124 @@ paths: - password responses: "200": - description: Login succeeded. + description: Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`). content: application/json: schema: - $ref: "#/components/schemas/LoginResponse" + anyOf: + - $ref: "#/components/schemas/LoginResponse" + - $ref: "#/components/schemas/MfaRequiredResponse" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/mfa/verify: + post: + tags: + - Auth + summary: Complete a second-factor login challenge + description: Presents the `mfaTicket` from the login `meta.mfaRequired` response plus a TOTP or recovery code. On + success returns the SAME token bundle / session the password path issues, with the session marked + second-factor-verified. The ticket is single-use; wrong codes are throttled and the ticket is burned at the + attempt cap. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MfaVerifyRequest" + responses: + "200": + description: Second factor verified — session + optional bearer issued. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaVerifyResponse" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/totp/setup: + post: + tags: + - Auth + summary: Begin TOTP enrollment (cookie session only) + description: Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is + not active until /confirm. Cookie-only — a Bearer token cannot enrol MFA. + responses: + "200": + description: Pending secret created. + content: + application/json: + schema: + $ref: "#/components/schemas/TotpSetupEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/totp/confirm: + post: + tags: + - Auth + summary: Confirm TOTP enrollment (cookie session only) + description: Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. + Cookie-only. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + pattern: ^\d{6}$ + required: + - code + responses: + "200": + description: Factor activated — recovery codes returned once. + content: + application/json: + schema: + $ref: "#/components/schemas/TotpConfirmEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/disable: + post: + tags: + - Auth + summary: Disable the second factor (step-up gated) + description: Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the + secret and deletes recovery codes. Bearer can never satisfy the step-up gate. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MfaDisableRequest" + responses: + "200": + description: Factor disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaDisableEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/recovery-codes/regenerate: + post: + tags: + - Auth + summary: Regenerate recovery codes (step-up gated) + description: Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can + never satisfy the gate. + responses: + "200": + description: Fresh recovery codes issued. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaRecoveryRegenEnvelope" "401": *a1 "422": *a3 "429": *a4 @@ -7492,6 +7612,43 @@ paths: "429": *a4 components: schemas: + MfaVerifyRequest: + type: object + properties: + mfaTicket: + type: string + minLength: 1 + description: The opaque single-use ticket from the login response. + method: + default: totp + type: string + enum: + - totp + - recovery + code: + type: string + minLength: 6 + maxLength: 32 + required: + - mfaTicket + - code + description: Complete a second-factor login challenge and receive the same token bundle the password path issues. + MfaDisableRequest: + type: object + properties: + code: + type: string + minLength: 6 + maxLength: 32 + method: + default: totp + type: string + enum: + - totp + - recovery + required: + - code + description: Disable the second factor. Requires fresh step-up plus a current TOTP or recovery code. PasskeyLoginVerifyRequest: type: object properties: @@ -10660,6 +10817,166 @@ components: - tokenExpiresAt additionalProperties: false description: Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`. + MfaRequiredResponse: + type: object + properties: + data: + type: "null" + error: + type: "null" + meta: + type: object + properties: + mfaRequired: + type: boolean + const: true + mfaTicket: + type: string + description: Opaque, single-use, ~5-minute ticket to present to /api/auth/mfa/verify. + methods: + type: array + items: + type: string + enum: + - totp + - recovery + description: Second factors the account can complete the challenge with. + required: + - mfaRequired + - mfaTicket + - methods + additionalProperties: false + required: + - data + - error + - meta + additionalProperties: false + description: Password accepted but a second factor is required. Not an error and not a session — no token is issued + until /api/auth/mfa/verify succeeds. + MfaVerifyResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AccessRefreshBundle" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TotpSetupEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/TotpSetupResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TotpSetupResponse: + type: object + properties: + otpauthUri: + type: string + description: otpauth:// URI to render as a QR code (carries the secret). + totpSecret: + type: string + description: Base32 secret for manual entry. Pending until confirmed. + required: + - otpauthUri + - totpSecret + additionalProperties: false + TotpConfirmEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MfaRecoveryCodesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MfaRecoveryCodesResponse: + type: object + properties: + enabled: + type: boolean + recoveryCodes: + type: array + items: + type: string + description: Single-use recovery codes, shown once. Save them now. + recoveryCodesRemaining: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - recoveryCodes + - recoveryCodesRemaining + additionalProperties: false + MfaDisableEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MfaToggleResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MfaToggleResponse: + type: object + properties: + enabled: + type: boolean + required: + - enabled + additionalProperties: false + MfaRecoveryRegenEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MfaRecoveryCodesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false PasskeyLoginVerifyResponse: type: object properties: diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts new file mode 100644 index 000000000..e47fc51fd --- /dev/null +++ b/src/__tests__/api/auth/login/mfa-branch.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findFirst: vi.fn() } }, +})); +vi.mock("@/lib/auth/password", () => ({ + verifyPassword: vi.fn().mockResolvedValue(true), +})); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: Date.now() + 1e6, + ip: "203.0.113.7", + }), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ data: { user: { id: "user-1" } }, error: null }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ), +})); +vi.mock("@/lib/auth/mfa/challenge", () => ({ + createMfaChallenge: vi.fn().mockResolvedValue({ + ticket: "opaque-ticket-value", + expiresAt: new Date(Date.now() + 5 * 60_000), + }), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "@/app/api/auth/login/route"; +import { prisma } from "@/lib/db"; +import { finishLogin } from "@/lib/auth/login-response"; +import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; + +function loginRequest() { + return new Request("http://localhost/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "user@example.com", + password: "correct-horse-battery", + }), + }) as unknown as Parameters[0]; +} + +const BASE_USER = { + id: "user-1", + username: "u", + email: "user@example.com", + passwordHash: "argon2-hash", + onboardingCompletedAt: new Date(), +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("login MFA branch", () => { + it("MFA user → returns mfaRequired + ticket and issues NO session", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...BASE_USER, + totpConfirmedAt: new Date(), + } as never); + + const res = await POST(loginRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.data).toBeNull(); + expect(body.error).toBeNull(); + expect(body.meta.mfaRequired).toBe(true); + expect(body.meta.mfaTicket).toBe("opaque-ticket-value"); + expect(body.meta.methods).toEqual(["totp", "recovery"]); + + expect(createMfaChallenge).toHaveBeenCalledWith("user-1", "login"); + // The session/token tail is never reached. + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("non-MFA user → issues the session via finishLogin, no challenge", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...BASE_USER, + totpConfirmedAt: null, + } as never); + + const res = await POST(loginRequest()); + expect(res.status).toBe(200); + + expect(finishLogin).toHaveBeenCalledTimes(1); + expect(createMfaChallenge).not.toHaveBeenCalled(); + const arg = vi.mocked(finishLogin).mock.calls[0][0]; + expect(arg.source).toBe("login.password"); + expect(arg.mfaVerified).toBeUndefined(); + }); + + it("wrong password never reaches the MFA branch", async () => { + const { verifyPassword } = await import("@/lib/auth/password"); + vi.mocked(verifyPassword).mockResolvedValueOnce(false); + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...BASE_USER, + totpConfirmedAt: new Date(), + } as never); + + const res = await POST(loginRequest()); + expect(res.status).toBe(401); + expect(createMfaChallenge).not.toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/api/auth/mfa/verify.test.ts b/src/__tests__/api/auth/mfa/verify.test.ts new file mode 100644 index 000000000..b16904be3 --- /dev/null +++ b/src/__tests__/api/auth/mfa/verify.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 1e6, + ip: "203.0.113.9", + }), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ data: { user: { id: "user-1" } }, error: null }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), +})); +vi.mock("@/lib/auth/mfa/challenge", () => ({ + loadActiveChallenge: vi.fn(), + recordChallengeFailure: vi + .fn() + .mockResolvedValue({ exhausted: false, attempts: 1 }), + claimChallenge: vi.fn(), +})); +vi.mock("@/lib/auth/mfa/verify-factor", () => ({ + verifyMfaFactor: vi.fn(), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "@/app/api/auth/mfa/verify/route"; +import { prisma } from "@/lib/db"; +import { finishLogin } from "@/lib/auth/login-response"; +import { + loadActiveChallenge, + recordChallengeFailure, + claimChallenge, +} from "@/lib/auth/mfa/challenge"; +import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; + +function verifyRequest(body: unknown) { + return new Request("http://localhost/api/auth/mfa/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as Parameters[0]; +} + +const GOOD_BODY = { mfaTicket: "tkt", method: "totp", code: "123456" }; +const ACTIVE_CHALLENGE = { + id: "ch-1", + userId: "user-1", + kind: "login", + attempts: 0, + expiresAt: new Date(Date.now() + 60_000), +}; +const MFA_USER = { + id: "user-1", + username: "u", + email: "user@example.com", + onboardingCompletedAt: new Date(), + totpConfirmedAt: new Date(), + totpSecretEncrypted: "v2.cipher", + totpLastStep: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/auth/mfa/verify", () => { + it("happy path: valid factor → claims ticket and issues the bundle", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never); + vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true, replay: false }); + vi.mocked(claimChallenge).mockResolvedValue(true); + + const res = await POST(verifyRequest(GOOD_BODY)); + expect(res.status).toBe(200); + + expect(claimChallenge).toHaveBeenCalledWith("ch-1"); + expect(finishLogin).toHaveBeenCalledTimes(1); + const arg = vi.mocked(finishLogin).mock.calls[0][0]; + expect(arg.mfaVerified).toBe(true); + expect(arg.source).toBe("mfa.verify"); + }); + + it("invalid ticket → 401, no session", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(null); + const res = await POST(verifyRequest(GOOD_BODY)); + expect(res.status).toBe(401); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("wrong code → records failure, 401, no session", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never); + vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: false, replay: false }); + + const res = await POST(verifyRequest(GOOD_BODY)); + expect(res.status).toBe(401); + expect(recordChallengeFailure).toHaveBeenCalledWith("ch-1"); + expect(claimChallenge).not.toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("factor valid but claim lost (race) → 401, no session", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never); + vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true, replay: false }); + vi.mocked(claimChallenge).mockResolvedValue(false); + + const res = await POST(verifyRequest(GOOD_BODY)); + expect(res.status).toBe(401); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("factor disabled between password and verify → failure, 401", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...MFA_USER, + totpConfirmedAt: null, + } as never); + + const res = await POST(verifyRequest(GOOD_BODY)); + expect(res.status).toBe(401); + expect(recordChallengeFailure).toHaveBeenCalledWith("ch-1"); + expect(verifyMfaFactor).not.toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("malformed body → 422", async () => { + const res = await POST(verifyRequest({ mfaTicket: "", code: "x" })); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index c4e727ee0..d271cfd31 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,22 +1,16 @@ import { prisma } from "@/lib/db"; import { loginPasswordSchema } from "@/lib/validations/auth"; import { verifyPassword } from "@/lib/auth/password"; -import { createSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { apiError, safeJson } from "@/lib/api-response"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token"; -import { - resolveTokenPolicy, - shouldIssueBearerToken, - isCookielessNativeCaller, -} from "@/lib/auth/native-client"; -import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; +import { finishLogin } from "@/lib/auth/login-response"; +import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; export const POST = apiHandler(async (request: NextRequest) => { // v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter @@ -95,93 +89,55 @@ export const POST = apiHandler(async (request: NextRequest) => { } const ua = request.headers.get("user-agent"); - // v1.4.22 W5 reconcile (Sr-H1) — `createSession` now anchors the - // `hl_onboarding` cookie itself; pass the user's onboarding state - // through so the proxy can short-circuit the redirect before the - // page hydrates. - await createSession(user.id, user.onboardingCompletedAt == null, ip, ua); await auditLog("auth.login.password", { userId: user.id, ipAddress: ip, }); - annotate({ action: { name: "auth.login.password" } }); - - // v1.4 G4: Native callers (iOS, n8n, Health-Connect, unrecognised UAs) - // get a 24h access token + 60d rotating refresh token. Web UAs keep the - // legacy 90d Bearer (issued only when X-Client-Type: native is set — - // browser flows continue to use the session cookie). - if ( - shouldIssueBearerToken(request.headers) || - isNativeClientRequest(request.headers) - ) { - const policy = resolveTokenPolicy(request.headers); - const deviceId = request.headers.get("x-device-id"); - - // M-3 hardening: a 60-day refresh token is only ever delivered to a - // genuinely cookie-less native caller. A browser spoofing - // `X-Client-Type: native` (Mozilla UA or an inbound session cookie) - // falls through to the plain access-token path below — never handed a - // long-lived secret into a DOM/XSS-reachable context. - if ( - policy.refreshTokenDays !== null && - isCookielessNativeCaller(request.headers) - ) { - const bundle = await issueAccessAndRefresh({ - userId: user.id, - policy, - deviceId, - userAgent: ua, - ipAddress: ip, - source: "login.password", - }); - await auditLog("auth.token.autoissue.native", { - userId: user.id, - ipAddress: ip, - details: { source: "login.password", policy: "native" }, - }); - annotate({ - action: { name: "auth.token.autoissue.native" }, - meta: { token_policy: "native" }, - }); - return apiSuccess({ - user: { id: user.id, username: user.username }, - token: bundle.accessToken, - tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(), - refreshToken: bundle.refreshToken, - refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(), - }); - } - - // Web policy with explicit X-Client-Type:native (legacy iOS auto-login) - const issued = await issueApiToken({ - userId: user.id, - name: `web auto-login ${new Date().toISOString()}`, - permissions: ["*"], - expiresInDays: policy.accessTokenDays, - }); - await auditLog("auth.token.autoissue.native", { + // v1.23 — second-factor gate. The password is correct, but an account with + // a confirmed TOTP factor is NOT yet authenticated: no `Session` row and no + // token bundle is minted here. Instead a single-use, ~5-minute MFA ticket is + // returned (the partial state lives in the ticket, never in a half-built + // session) and the client completes the login at `/api/auth/mfa/verify`. + // + // Enumeration note: `meta.mfaRequired` is only ever returned AFTER a valid + // password, and the invalid-credentials response above is identical + // regardless of MFA state — an attacker without the password cannot learn + // whether an account has MFA. + if (user.totpConfirmedAt) { + const challenge = await createMfaChallenge(user.id, "login"); + await auditLog("auth.mfa.challenge", { userId: user.id, ipAddress: ip, - details: { - tokenId: issued.tokenId, - source: "login.password", - policy: "web", - }, + details: { source: "login.password" }, }); annotate({ - action: { name: "auth.token.autoissue.native" }, - meta: { token_policy: "web" }, - }); - return apiSuccess({ - user: { id: user.id, username: user.username }, - token: issued.token, - tokenExpiresAt: issued.expiresAt.toISOString(), + action: { name: "auth.mfa.challenge" }, + meta: { mfa_required: true }, }); + return NextResponse.json( + { + data: null, + error: null, + meta: { + mfaRequired: true, + mfaTicket: challenge.ticket, + methods: ["totp", "recovery"], + }, + }, + { status: 200 }, + ); } - return apiSuccess({ - user: { id: user.id, username: user.username }, + annotate({ action: { name: "auth.login.password" } }); + + // No second factor — issue the session/token exactly as before. + return finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "login.password", }); }); diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts new file mode 100644 index 000000000..adf7dfde8 --- /dev/null +++ b/src/app/api/auth/mfa/verify/route.ts @@ -0,0 +1,139 @@ +/** + * POST /api/auth/mfa/verify + * + * Complete a second-factor login challenge. The caller presents the opaque + * `mfaTicket` from the `meta.mfaRequired` login response plus a TOTP or + * recovery code. On success the **same** session/token bundle the password + * path issues is returned (web → session cookie; native → access+refresh), + * with the session stamped `mfaVerifiedAt`. + * + * Security-critical invariants: + * - No session or token exists until the factor passes — the partial state + * lived in the ticket, never in a half-built session. + * - The ticket is **claimed once, atomically**, only AFTER the factor + * verifies; two concurrent valid submissions can mint at most one session. + * - Wrong factors increment the ticket attempt counter and burn it at the + * cap (NIST throttle, not an account lock). Anonymous surface is rate- + * limited per IP on top of the per-ticket cap. + */ +import { NextRequest } from "next/server"; +import { apiError, safeJson } from "@/lib/api-response"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { finishLogin } from "@/lib/auth/login-response"; +import { + loadActiveChallenge, + recordChallengeFailure, + claimChallenge, +} from "@/lib/auth/mfa/challenge"; +import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { mfaVerifySchema } from "@/lib/validations/mfa"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:mfa-verify", + 10, + 15 * 60 * 1000, + ); + const ip = rl.ip ?? "unknown"; + if (!rl.allowed) { + return apiError("Too many attempts. Please try again later.", 429, { + headers: rateLimitHeaders(rl), + }); + } + + await ensureDbCompatibility(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = mfaVerifySchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + const { mfaTicket, method, code } = parsed.data; + + // Resolve the ticket. A single generic 401 covers unknown / expired / + // consumed / over-cap so a ticket-guesser learns nothing. + const challenge = await loadActiveChallenge(mfaTicket); + if (!challenge) { + annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } }); + return apiError("Invalid or expired challenge", 401); + } + + const user = await prisma.user.findUnique({ + where: { id: challenge.userId }, + select: { + id: true, + username: true, + email: true, + onboardingCompletedAt: true, + totpConfirmedAt: true, + totpSecretEncrypted: true, + totpLastStep: true, + }, + }); + + // The factor must still be active (it could have been disabled between the + // password step and this call). Treat anything else as a failed attempt. + if (!user || !user.totpConfirmedAt) { + await recordChallengeFailure(challenge.id); + annotate({ action: { name: "auth.mfa.verify.no_active_factor" } }); + return apiError("Invalid or expired challenge", 401); + } + + const factor = await verifyMfaFactor(user, method, code); + if (!factor.ok) { + const { exhausted } = await recordChallengeFailure(challenge.id); + await auditLog("auth.mfa.failed", { + userId: user.id, + ipAddress: ip, + details: { stage: "login", method, replay: factor.replay, exhausted }, + }); + annotate({ + action: { name: "auth.mfa.failed" }, + meta: { method, replay: factor.replay, exhausted }, + }); + return apiError("Invalid code", 401); + } + + // Factor verified — claim the ticket atomically. Only the single winning + // caller proceeds to mint a session; a concurrent claim loses here. + const claimed = await claimChallenge(challenge.id); + if (!claimed) { + annotate({ action: { name: "auth.mfa.verify.claim_lost" } }); + return apiError("Invalid or expired challenge", 401); + } + + await auditLog("auth.mfa.verified", { + userId: user.id, + ipAddress: ip, + details: { method }, + }); + if (method === "recovery") { + await auditLog("auth.mfa.recovery_used", { + userId: user.id, + ipAddress: ip, + }); + } + annotate({ action: { name: "auth.mfa.verified" }, meta: { method } }); + + const ua = request.headers.get("user-agent"); + return finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "mfa.verify", + mfaVerified: true, + }); +}); diff --git a/src/lib/auth/login-response.ts b/src/lib/auth/login-response.ts new file mode 100644 index 000000000..519c1f81d --- /dev/null +++ b/src/lib/auth/login-response.ts @@ -0,0 +1,125 @@ +/** + * Shared "issue the authenticated response" tail used by both the password + * login route and the `/api/auth/mfa/verify` route. + * + * Keeping one implementation guarantees the transport contract is identical + * whether or not a second factor sat in the middle: a web caller gets a + * session cookie, a cookie-less native caller gets the access + refresh + * bundle, and a legacy `X-Client-Type: native` caller gets a plain access + * token. The only behavioural difference the second factor introduces is the + * `mfaVerifiedAt` stamp threaded onto the issued session (cookie path) so + * step-up freshness can read it. + * + * Bearer-issuing branches do not carry an `mfaVerifiedAt` equivalent — the + * step-up primitive (`requireFreshMfa`) is cookie-only by construction, the + * same structural boundary as `requireAdmin`, so a token transport can never + * satisfy step-up regardless. + */ +import type { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { createSession } from "@/lib/auth/session"; +import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token"; +import { + resolveTokenPolicy, + shouldIssueBearerToken, + isCookielessNativeCaller, +} from "@/lib/auth/native-client"; +import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; +import type { User } from "@/generated/prisma/client"; + +export interface FinishLoginParams { + user: Pick; + request: NextRequest; + ip: string; + userAgent: string | null; + /** Audit `source` discriminator, e.g. `"login.password"` / `"mfa.verify"`. */ + source: string; + /** + * When true the issued session is stamped `mfaVerifiedAt = now()` so + * step-up gates treat it as freshly second-factor-verified. Only the + * cookie path carries the stamp. + */ + mfaVerified?: boolean; +} + +/** + * Mint the session/token for an already-authenticated user and return the + * client response. Caller is responsible for the prior factor checks and for + * its own `auth.login.*` / `auth.mfa.*` audit entries; this writes only the + * token-issuance audit rows. + */ +export async function finishLogin( + params: FinishLoginParams, +): Promise { + const { user, request, ip, userAgent, source, mfaVerified } = params; + + if ( + shouldIssueBearerToken(request.headers) || + isNativeClientRequest(request.headers) + ) { + const policy = resolveTokenPolicy(request.headers); + const deviceId = request.headers.get("x-device-id"); + + // A 60-day refresh token is only ever delivered to a genuinely + // cookie-less native caller — a browser spoofing `X-Client-Type: native` + // falls through to the plain access-token path below. + if ( + policy.refreshTokenDays !== null && + isCookielessNativeCaller(request.headers) + ) { + const bundle = await issueAccessAndRefresh({ + userId: user.id, + policy, + deviceId, + userAgent, + ipAddress: ip, + source, + }); + await auditLog("auth.token.autoissue.native", { + userId: user.id, + ipAddress: ip, + details: { source, policy: "native" }, + }); + return apiSuccess({ + user: { id: user.id, username: user.username }, + token: bundle.accessToken, + tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(), + refreshToken: bundle.refreshToken, + refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(), + }); + } + + // Web policy with explicit X-Client-Type:native (legacy iOS auto-login). + const issued = await issueApiToken({ + userId: user.id, + name: `web auto-login ${new Date().toISOString()}`, + permissions: ["*"], + expiresInDays: policy.accessTokenDays, + }); + await auditLog("auth.token.autoissue.native", { + userId: user.id, + ipAddress: ip, + details: { tokenId: issued.tokenId, source, policy: "web" }, + }); + return apiSuccess({ + user: { id: user.id, username: user.username }, + token: issued.token, + tokenExpiresAt: issued.expiresAt.toISOString(), + }); + } + + // Browser path — session cookie. `createSession` anchors the onboarding + // cookie itself; thread the user's onboarding state through. + await createSession( + user.id, + user.onboardingCompletedAt == null, + ip, + userAgent, + mfaVerified ? new Date() : null, + ); + + return apiSuccess({ + user: { id: user.id, username: user.username }, + }); +} diff --git a/src/lib/auth/mfa/__tests__/challenge.test.ts b/src/lib/auth/mfa/__tests__/challenge.test.ts new file mode 100644 index 000000000..74e4bfc47 --- /dev/null +++ b/src/lib/auth/mfa/__tests__/challenge.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + mfaChallenge: { + create: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + }, +})); + +// Stand-in for the real HMAC: deterministic but NOT containing the raw +// ticket, so the "raw ticket is never persisted" assertion is meaningful. +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn((t: string) => `H${t.length}`), +})); + +import { + createMfaChallenge, + loadActiveChallenge, + recordChallengeFailure, + claimChallenge, + MFA_CHALLENGE_ATTEMPT_CAP, +} from "../challenge"; +import { prisma } from "@/lib/db"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function activeRow(overrides: Record = {}) { + return { + id: "ch-1", + userId: "user-1", + kind: "login", + attempts: 0, + expiresAt: new Date(Date.now() + 60_000), + consumedAt: null, + ...overrides, + }; +} + +describe("mfa challenge", () => { + it("mints a ticket and stores only its hash", async () => { + vi.mocked(prisma.mfaChallenge.create).mockResolvedValue({} as never); + const { ticket } = await createMfaChallenge("user-1", "login"); + expect(typeof ticket).toBe("string"); + expect(ticket.length).toBeGreaterThan(20); + const arg = vi.mocked(prisma.mfaChallenge.create).mock.calls[0][0]; + expect(arg.data.ticketHash).toBe(`H${ticket.length}`); + // Raw ticket is never persisted. + expect(JSON.stringify(arg)).not.toContain(ticket); + }); + + it("loads an active challenge by hash", async () => { + vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue( + activeRow() as never, + ); + const res = await loadActiveChallenge("tkt"); + expect(res?.id).toBe("ch-1"); + expect( + vi.mocked(prisma.mfaChallenge.findUnique).mock.calls[0][0], + ).toMatchObject({ where: { ticketHash: "H3" } }); + }); + + it("refuses a consumed ticket (single-use)", async () => { + vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue( + activeRow({ consumedAt: new Date() }) as never, + ); + expect(await loadActiveChallenge("tkt")).toBeNull(); + }); + + it("refuses an expired ticket (TTL)", async () => { + vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue( + activeRow({ expiresAt: new Date(Date.now() - 1000) }) as never, + ); + expect(await loadActiveChallenge("tkt")).toBeNull(); + }); + + it("refuses a ticket at the attempt cap", async () => { + vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue( + activeRow({ attempts: MFA_CHALLENGE_ATTEMPT_CAP }) as never, + ); + expect(await loadActiveChallenge("tkt")).toBeNull(); + }); + + it("refuses an unknown ticket", async () => { + vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue(null as never); + expect(await loadActiveChallenge("nope")).toBeNull(); + }); + + it("burns the ticket when the attempt cap is reached", async () => { + vi.mocked(prisma.mfaChallenge.update).mockResolvedValue({ + attempts: MFA_CHALLENGE_ATTEMPT_CAP, + } as never); + vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await recordChallengeFailure("ch-1"); + expect(res.exhausted).toBe(true); + // Burn sets consumedAt only while still null (claim-once guard). + expect(prisma.mfaChallenge.updateMany).toHaveBeenCalledWith({ + where: { id: "ch-1", consumedAt: null }, + data: { consumedAt: expect.any(Date) }, + }); + }); + + it("does not burn below the attempt cap", async () => { + vi.mocked(prisma.mfaChallenge.update).mockResolvedValue({ + attempts: 1, + } as never); + const res = await recordChallengeFailure("ch-1"); + expect(res.exhausted).toBe(false); + expect(prisma.mfaChallenge.updateMany).not.toHaveBeenCalled(); + }); + + it("claim-once: only the winning update returns true", async () => { + vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValueOnce({ + count: 1, + } as never); + expect(await claimChallenge("ch-1")).toBe(true); + + vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValueOnce({ + count: 0, + } as never); + expect(await claimChallenge("ch-1")).toBe(false); + }); +}); diff --git a/src/lib/auth/mfa/challenge.ts b/src/lib/auth/mfa/challenge.ts new file mode 100644 index 000000000..9e641982d --- /dev/null +++ b/src/lib/auth/mfa/challenge.ts @@ -0,0 +1,133 @@ +/** + * MfaChallenge — the short-lived, single-use login step-up ticket. + * + * When a password (or other primary credential) is accepted for an account + * that has a second factor enabled, **no session or token is issued**. + * Instead a challenge row is minted: the partial "password OK, awaiting + * factor 2" state lives entirely in this row, never in a half-built + * `Session`. The caller receives an opaque ticket; only its hash + * (`hashToken`) is stored, so a leaked database row cannot reconstruct a + * usable ticket. + * + * Guarantees enforced here: + * - **TTL** (~5 min) — `expiresAt`; an expired ticket is never loadable. + * - **Attempt cap** — `attempts` is incremented on every wrong factor and + * the ticket is burned (`consumedAt` set) once the cap is hit, forcing a + * fresh password login (NIST throttle, not an account lock). + * - **Claim-once** — consuming a ticket is an atomic guarded update + * (`consumedAt: null` in the WHERE), so two concurrent verifications can + * never both succeed and mint two sessions. The factor is verified first; + * the session is issued only after the claim wins. + */ +import { randomBytes } from "node:crypto"; +import { hashToken } from "@/lib/auth/hmac"; +import { prisma } from "@/lib/db"; + +/** Login step-up: the only `kind` Phase M exercises. */ +export type MfaChallengeKind = "login"; + +/** 5-minute ticket life — long enough to read a code, short enough to bound replay. */ +export const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000; +/** Wrong-factor attempts before the ticket is burned. */ +export const MFA_CHALLENGE_ATTEMPT_CAP = 5; + +export interface CreatedChallenge { + /** The opaque ticket handed to the client (never stored in the clear). */ + ticket: string; + expiresAt: Date; +} + +/** Mint a fresh single-use challenge for a user/kind. */ +export async function createMfaChallenge( + userId: string, + kind: MfaChallengeKind, +): Promise { + const ticket = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + MFA_CHALLENGE_TTL_MS); + await prisma.mfaChallenge.create({ + data: { + userId, + kind, + ticketHash: hashToken(ticket), + expiresAt, + }, + }); + return { ticket, expiresAt }; +} + +export interface ActiveChallenge { + id: string; + userId: string; + kind: string; + attempts: number; + expiresAt: Date; +} + +/** + * Resolve a presented ticket to its live challenge, or null when it is + * unknown / already consumed / expired / over the attempt cap. The lookup + * is by hash — the raw ticket is never compared against a stored plaintext. + */ +export async function loadActiveChallenge( + ticket: string, +): Promise { + const row = await prisma.mfaChallenge.findUnique({ + where: { ticketHash: hashToken(ticket) }, + select: { + id: true, + userId: true, + kind: true, + attempts: true, + expiresAt: true, + consumedAt: true, + }, + }); + if (!row) return null; + if (row.consumedAt !== null) return null; + if (row.expiresAt.getTime() <= Date.now()) return null; + if (row.attempts >= MFA_CHALLENGE_ATTEMPT_CAP) return null; + return { + id: row.id, + userId: row.userId, + kind: row.kind, + attempts: row.attempts, + expiresAt: row.expiresAt, + }; +} + +/** + * Record a failed factor attempt. Increments `attempts`; when the cap is + * reached the ticket is burned (`consumedAt` set) so it cannot be retried. + * Returns whether the ticket is now exhausted. + */ +export async function recordChallengeFailure( + challengeId: string, +): Promise<{ exhausted: boolean; attempts: number }> { + const updated = await prisma.mfaChallenge.update({ + where: { id: challengeId }, + data: { attempts: { increment: 1 } }, + select: { attempts: true }, + }); + const exhausted = updated.attempts >= MFA_CHALLENGE_ATTEMPT_CAP; + if (exhausted) { + await prisma.mfaChallenge.updateMany({ + where: { id: challengeId, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + } + return { exhausted, attempts: updated.attempts }; +} + +/** + * Atomically claim the ticket. Returns true only for the single caller that + * transitions `consumedAt` from null → now. The factor MUST already be + * verified before this is called; the session is issued only when this + * returns true. + */ +export async function claimChallenge(challengeId: string): Promise { + const claimed = await prisma.mfaChallenge.updateMany({ + where: { id: challengeId, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + return claimed.count === 1; +} diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 77a6002ac..28736b8af 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -8,6 +8,11 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { loginPasswordSchema } from "@/lib/validations/auth"; +import { + mfaVerifySchema, + totpConfirmSchema, + mfaDisableSchema, +} from "@/lib/validations/mfa"; import { dataEnvelope, stdResponses } from "./shared"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -73,23 +78,193 @@ const accessRefreshBundle = z "Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`.", }); +// ── v1.23 second-factor (MFA) shapes ───────────────────────────────── + +const mfaRequiredEnvelope = z + .object({ + data: z.null(), + error: z.null(), + meta: z.object({ + mfaRequired: z.literal(true), + mfaTicket: z + .string() + .describe( + "Opaque, single-use, ~5-minute ticket to present to /api/auth/mfa/verify.", + ), + methods: z + .array(z.enum(["totp", "recovery"])) + .describe( + "Second factors the account can complete the challenge with.", + ), + }), + }) + .meta({ + id: "MfaRequiredResponse", + description: + "Password accepted but a second factor is required. Not an error and not a session — no token is issued until /api/auth/mfa/verify succeeds.", + }); + +const totpSetupResponse = z + .object({ + otpauthUri: z + .string() + .describe("otpauth:// URI to render as a QR code (carries the secret)."), + totpSecret: z + .string() + .describe("Base32 secret for manual entry. Pending until confirmed."), + }) + .meta({ id: "TotpSetupResponse" }); + +const recoveryCodesResponse = z + .object({ + enabled: z.boolean().optional(), + recoveryCodes: z + .array(z.string()) + .describe("Single-use recovery codes, shown once. Save them now."), + recoveryCodesRemaining: z.number().int(), + }) + .meta({ id: "MfaRecoveryCodesResponse" }); + +const mfaToggleResponse = z + .object({ enabled: z.boolean() }) + .meta({ id: "MfaToggleResponse" }); + export const authPaths: NonNullable = { "/api/auth/login": { post: { tags: ["Auth"], summary: "Email-or-username login (password)", description: - "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.", + "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.\n\n" + + "v1.23 — when the account has a confirmed second factor, the response carries no session/token. It returns HTTP 200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the `meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token bundle. Accounts without MFA are unchanged.", requestBody: { required: true, content: { "application/json": { schema: loginPasswordSchema } }, }, responses: { "200": { - description: "Login succeeded.", + description: + "Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`).", content: { "application/json": { - schema: dataEnvelope(accessRefreshBundle, "LoginResponse"), + schema: z.union([ + dataEnvelope(accessRefreshBundle, "LoginResponse"), + mfaRequiredEnvelope, + ]), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/mfa/verify": { + post: { + tags: ["Auth"], + summary: "Complete a second-factor login challenge", + description: + "Presents the `mfaTicket` from the login `meta.mfaRequired` response plus a TOTP or recovery code. On success returns the SAME token bundle / session the password path issues, with the session marked second-factor-verified. The ticket is single-use; wrong codes are throttled and the ticket is burned at the attempt cap.", + requestBody: { + required: true, + content: { "application/json": { schema: mfaVerifySchema } }, + }, + responses: { + "200": { + description: + "Second factor verified — session + optional bearer issued.", + content: { + "application/json": { + schema: dataEnvelope(accessRefreshBundle, "MfaVerifyResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/mfa/totp/setup": { + post: { + tags: ["Auth"], + summary: "Begin TOTP enrollment (cookie session only)", + description: + "Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm. Cookie-only — a Bearer token cannot enrol MFA.", + responses: { + "200": { + description: "Pending secret created.", + content: { + "application/json": { + schema: dataEnvelope(totpSetupResponse, "TotpSetupEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/mfa/totp/confirm": { + post: { + tags: ["Auth"], + summary: "Confirm TOTP enrollment (cookie session only)", + description: + "Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. Cookie-only.", + requestBody: { + required: true, + content: { "application/json": { schema: totpConfirmSchema } }, + }, + responses: { + "200": { + description: "Factor activated — recovery codes returned once.", + content: { + "application/json": { + schema: dataEnvelope( + recoveryCodesResponse, + "TotpConfirmEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/mfa/disable": { + post: { + tags: ["Auth"], + summary: "Disable the second factor (step-up gated)", + description: + "Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the secret and deletes recovery codes. Bearer can never satisfy the step-up gate.", + requestBody: { + required: true, + content: { "application/json": { schema: mfaDisableSchema } }, + }, + responses: { + "200": { + description: "Factor disabled.", + content: { + "application/json": { + schema: dataEnvelope(mfaToggleResponse, "MfaDisableEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/mfa/recovery-codes/regenerate": { + post: { + tags: ["Auth"], + summary: "Regenerate recovery codes (step-up gated)", + description: + "Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can never satisfy the gate.", + responses: { + "200": { + description: "Fresh recovery codes issued.", + content: { + "application/json": { + schema: dataEnvelope( + recoveryCodesResponse, + "MfaRecoveryRegenEnvelope", + ), }, }, }, diff --git a/tests/integration/mfa-login-flow.test.ts b/tests/integration/mfa-login-flow.test.ts new file mode 100644 index 000000000..f646d7ad5 --- /dev/null +++ b/tests/integration/mfa-login-flow.test.ts @@ -0,0 +1,155 @@ +/** + * Integration guard for the second-factor login path against real Postgres. + * + * Unit tests mock Prisma, so they cannot prove the security-critical DB-backed + * invariants: that a verified TOTP step is actually persisted (replay guard), + * that the recovery-code burn is durable and one-time, and that the challenge + * ticket can be claimed exactly once. This exercises all three end to end. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as OTPAuth from "otpauth"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +// hashToken (challenge ticket hashing) is fail-closed on a weak/absent key. +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-test-hmac-key-test-hmac-key-0123456789"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +function codeAt(secretBase32: string, atMs: number): string { + const totp = new OTPAuth.TOTP({ + issuer: "HealthLog", + label: "HealthLog", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(secretBase32), + }); + return totp.generate({ timestamp: atMs }); +} + +let secret: string; +let encrypt: (s: string) => string; + +beforeAll(async () => { + ({ encrypt } = await import("@/lib/crypto")); + const { generateTotpSecret } = await import("@/lib/auth/mfa/totp"); + secret = generateTotpSecret(); +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("MFA login flow (real Postgres)", () => { + it("TOTP verify persists the step and rejects a replay", async () => { + const { verifyMfaFactor } = await import("@/lib/auth/mfa/verify-factor"); + const prisma = getPrismaClient(); + + const user = await prisma.user.create({ + data: { + username: "totp-user", + email: "totp@example.test", + totpSecretEncrypted: encrypt(secret), + totpConfirmedAt: new Date(), + }, + }); + + // verifyMfaFactor verifies against the real wall clock, so the code must + // be generated for "now". + const code = codeAt(secret, Date.now()); + + const first = await verifyMfaFactor( + { id: user.id, totpSecretEncrypted: encrypt(secret), totpLastStep: null }, + "totp", + code, + ); + expect(first.ok).toBe(true); + + const afterFirst = await prisma.user.findUnique({ + where: { id: user.id }, + select: { totpLastStep: true }, + }); + expect(afterFirst?.totpLastStep).not.toBeNull(); + + // Replay the same code — the persisted step now floors it out. + const replay = await verifyMfaFactor( + { + id: user.id, + totpSecretEncrypted: encrypt(secret), + totpLastStep: afterFirst?.totpLastStep ?? null, + }, + "totp", + code, + ); + expect(replay.ok).toBe(false); + expect(replay.replay).toBe(true); + }); + + it("challenge is single-use and burns at the attempt cap", async () => { + const { + createMfaChallenge, + loadActiveChallenge, + claimChallenge, + recordChallengeFailure, + MFA_CHALLENGE_ATTEMPT_CAP, + } = await import("@/lib/auth/mfa/challenge"); + const prisma = getPrismaClient(); + + const user = await prisma.user.create({ + data: { username: "ch-user", email: "ch@example.test" }, + }); + + const { ticket } = await createMfaChallenge(user.id, "login"); + const active = await loadActiveChallenge(ticket); + expect(active).not.toBeNull(); + + // Claim-once: first claim wins, second loses, ticket no longer loadable. + expect(await claimChallenge(active!.id)).toBe(true); + expect(await claimChallenge(active!.id)).toBe(false); + expect(await loadActiveChallenge(ticket)).toBeNull(); + + // Attempt cap burns a fresh ticket. + const second = await createMfaChallenge(user.id, "login"); + const row = await loadActiveChallenge(second.ticket); + let exhausted = false; + for (let i = 0; i < MFA_CHALLENGE_ATTEMPT_CAP; i++) { + ({ exhausted } = await recordChallengeFailure(row!.id)); + } + expect(exhausted).toBe(true); + expect(await loadActiveChallenge(second.ticket)).toBeNull(); + }); + + it("recovery code is one-time-use and regeneration invalidates the set", async () => { + const { + generateRecoveryCodes, + persistRecoveryCodes, + verifyAndConsumeRecoveryCode, + countRemainingRecoveryCodes, + regenerateRecoveryCodes, + } = await import("@/lib/auth/mfa/recovery-codes"); + const prisma = getPrismaClient(); + + const user = await prisma.user.create({ + data: { username: "rec-user", email: "rec@example.test" }, + }); + + const codes = generateRecoveryCodes(); + await persistRecoveryCodes(prisma, user.id, codes); + expect(await countRemainingRecoveryCodes(user.id)).toBe(codes.length); + + // First use succeeds, second use of the same code fails. + expect(await verifyAndConsumeRecoveryCode(user.id, codes[0])).toBe(true); + expect(await verifyAndConsumeRecoveryCode(user.id, codes[0])).toBe(false); + expect(await countRemainingRecoveryCodes(user.id)).toBe(codes.length - 1); + + // Regeneration drops every prior code (including the one not yet used). + const fresh = await regenerateRecoveryCodes(user.id); + expect(await countRemainingRecoveryCodes(user.id)).toBe(fresh.length); + expect(await verifyAndConsumeRecoveryCode(user.id, codes[1])).toBe(false); + expect(await verifyAndConsumeRecoveryCode(user.id, fresh[0])).toBe(true); + }); +}); From 8f56ac95d89004e6952f3730097f61dc636c2a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:08:10 +0200 Subject: [PATCH 06/34] fix(auth): harden the second-factor verify and disable surfaces Reject any challenge that is not a login challenge before minting a session, cap the disable and confirm request bodies through safeJson, and correct the recovery-code scan comment to match its early-return-on-match behaviour. --- src/app/api/auth/me/mfa/disable/route.ts | 15 ++++++++++----- src/app/api/auth/me/mfa/totp/confirm/route.ts | 15 ++++++++++----- src/app/api/auth/mfa/verify/route.ts | 8 ++++++++ src/lib/auth/mfa/recovery-codes.ts | 5 +++-- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index 24a266288..9595de572 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -16,7 +16,12 @@ import { HttpError, MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -47,10 +52,10 @@ export const POST = apiHandler(async (req: Request) => { return res; } - let body: unknown; - try { - body = await req.json(); - } catch { + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 4096, + }); + if (jsonError) { throw new HttpError(400, "Invalid JSON body"); } const parsed = mfaDisableSchema.safeParse(body); diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 861c7c7a3..840e48fa7 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -13,7 +13,12 @@ * Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. */ import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -54,10 +59,10 @@ export const POST = apiHandler(async (req: Request) => { throw new HttpError(409, "Start enrollment before confirming"); } - let body: unknown; - try { - body = await req.json(); - } catch { + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 4096, + }); + if (jsonError) { throw new HttpError(400, "Invalid JSON body"); } const parsed = totpConfirmSchema.safeParse(body); diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index adf7dfde8..4e0829821 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -70,6 +70,14 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid or expired challenge", 401); } + // Only a login-issued challenge may be redeemed for a session here. A future + // step-up or webauthn challenge kind must never mint a login session through + // this endpoint, so reject anything but "login" with the same generic 401. + if (challenge.kind !== "login") { + annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } }); + return apiError("Invalid or expired challenge", 401); + } + const user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: { diff --git a/src/lib/auth/mfa/recovery-codes.ts b/src/lib/auth/mfa/recovery-codes.ts index 6e9caa54e..8d73dbe9d 100644 --- a/src/lib/auth/mfa/recovery-codes.ts +++ b/src/lib/auth/mfa/recovery-codes.ts @@ -117,8 +117,9 @@ export async function verifyAndConsumeRecoveryCode( }); for (const row of rows) { - // Argon2id verify is constant-time; iterate the full set so the match - // position is not observable through timing. + // Argon2id verify is constant-time per code. A match returns early (the 200 + // already reveals validity, so match-position timing leaks nothing useful); + // a miss scans on, so a wrong code never reveals how many remain. const match = await verifyPassword(row.codeHash, normalised); if (!match) continue; From 0ba77778d5c6eba02ba2a0b8471f85fbd4227af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:27:07 +0200 Subject: [PATCH 07/34] feat(export): offer a passphrase-encrypted archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in to download the full backup as an encrypted archive instead of a plaintext JSON file. The user supplies a passphrase over the request; the server derives a 256-bit key with Argon2id and seals the export bytes with AES-256-GCM into a self-describing HLX1 archive (magic, version, KDF params, salt, iv, tag, ciphertext), mirroring the off-host backup envelope. POST /api/export/encrypted returns the binary archive and is step-up gated for accounts with a second factor; single-factor accounts use a normal session. The passphrase is never stored and never logged, and there is no server-side recovery — the UI states this. A standalone decryptor (scripts/decrypt-export.ts) opens the archive with the passphrase outside the app. The plaintext full-backup route now shares one payload builder so both variants emit the same restore-compatible shape. --- docs/api/openapi.yaml | 40 +++ messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- scripts/decrypt-export.ts | 102 ++++++++ .../export/encrypted/__tests__/route.test.ts | 116 +++++++++ src/app/api/export/encrypted/route.ts | 127 ++++++++++ src/app/api/export/full-backup/route.ts | 125 +--------- src/components/settings/export-section.tsx | 84 ++++++- .../__tests__/passphrase-archive.test.ts | 80 ++++++ src/lib/export/full-backup-payload.ts | 128 ++++++++++ src/lib/export/passphrase-archive.ts | 227 ++++++++++++++++++ src/lib/openapi/routes/export.ts | 51 ++++ src/lib/openapi/routes/index.ts | 2 + 17 files changed, 999 insertions(+), 125 deletions(-) create mode 100644 scripts/decrypt-export.ts create mode 100644 src/app/api/export/encrypted/__tests__/route.test.ts create mode 100644 src/app/api/export/encrypted/route.ts create mode 100644 src/lib/export/__tests__/passphrase-archive.test.ts create mode 100644 src/lib/export/full-backup-payload.ts create mode 100644 src/lib/export/passphrase-archive.ts create mode 100644 src/lib/openapi/routes/export.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4055a7446..24aa38241 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1349,6 +1349,35 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/export/encrypted: + post: + tags: + - Export + summary: Download the full backup as a passphrase-encrypted archive + description: v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive + (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a + `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`, + cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal + session or Bearer. Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten + passphrase makes the archive unrecoverable. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EncryptedExportRequest" + responses: + "200": + description: Encrypted archive. `application/octet-stream`; the body is the `HLX1` binary (magic | version | Argon2 + params | salt | iv | tag | ciphertext). + content: + application/octet-stream: + schema: + type: string + format: binary + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/login: post: tags: @@ -7612,6 +7641,17 @@ paths: "429": *a4 components: schemas: + EncryptedExportRequest: + type: object + properties: + passphrase: + type: string + minLength: 12 + maxLength: 1024 + description: User-chosen passphrase (>= 12 chars). Derives the archive key via Argon2id; never stored, never logged, no + server-side recovery. + required: + - passphrase MfaVerifyRequest: type: object properties: diff --git a/messages/de.json b/messages/de.json index 523158b31..99c4d6007 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Voll-Backup", - "description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload." + "description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload.", + "encryptToggle": "Mit einer Passphrase verschlüsseln", + "passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)", + "passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.", + "noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.", + "stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut." }, "cycle": { "title": "Zyklus", diff --git a/messages/en.json b/messages/en.json index 16ec52b02..c21dd63e9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Full backup", - "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload." + "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload.", + "encryptToggle": "Encrypt with a passphrase", + "passphrasePlaceholder": "Passphrase (min. 12 characters)", + "passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.", + "noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.", + "stepUpNeeded": "Confirm your second factor, then try again." }, "cycle": { "title": "Cycle", diff --git a/messages/es.json b/messages/es.json index 8bea74277..bed491e0e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Copia completa", - "description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador." + "description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador.", + "encryptToggle": "Cifrar con una contraseña", + "passphrasePlaceholder": "Contraseña (mín. 12 caracteres)", + "passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.", + "noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.", + "stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo." }, "cycle": { "title": "Ciclo", diff --git a/messages/fr.json b/messages/fr.json index d943773ff..e4f6a672f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Sauvegarde complète", - "description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur." + "description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur.", + "encryptToggle": "Chiffrer avec une phrase secrète", + "passphrasePlaceholder": "Phrase secrète (12 caractères min.)", + "passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.", + "noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.", + "stepUpNeeded": "Confirmez votre second facteur, puis réessayez." }, "cycle": { "title": "Cycle", diff --git a/messages/it.json b/messages/it.json index 38c204fbb..702f0f4ff 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Backup completo", - "description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore." + "description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore.", + "encryptToggle": "Cifra con una passphrase", + "passphrasePlaceholder": "Passphrase (min. 12 caratteri)", + "passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.", + "noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.", + "stepUpNeeded": "Conferma il secondo fattore e riprova." }, "cycle": { "title": "Ciclo", diff --git a/messages/pl.json b/messages/pl.json index 566625aa1..9f5463383 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Pełna kopia zapasowa", - "description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora." + "description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora.", + "encryptToggle": "Zaszyfruj hasłem-frazą", + "passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)", + "passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.", + "noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.", + "stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie." }, "cycle": { "title": "Cykl", diff --git a/scripts/decrypt-export.ts b/scripts/decrypt-export.ts new file mode 100644 index 000000000..3b93897bf --- /dev/null +++ b/scripts/decrypt-export.ts @@ -0,0 +1,102 @@ +/** + * scripts/decrypt-export.ts [output.json] + * + * Standalone decryptor for a passphrase-encrypted HealthLog export (`HLX1` + * wire format — see src/lib/export/passphrase-archive.ts). A user runs this + * with their passphrase to recover the plaintext JSON backup WITHOUT the app: + * + * pnpm dlx tsx scripts/decrypt-export.ts healthlog-backup-....hlx + * + * The passphrase is read interactively with echo disabled, or from the + * `EXPORT_PASSPHRASE` environment variable for non-interactive use. It is + * never written to disk or logged. There is no server-side recovery: a wrong + * or forgotten passphrase cannot be worked around — the archive is opaque. + * + * If no output path is given, the decrypted JSON is written next to the input + * with a `.json` extension; pass `-` to stream to stdout instead. + * + * This script depends ONLY on the archive codec (Argon2id + node:crypto) — no + * database, no Prisma, no app boot — so it stays runnable from a checkout with + * just `pnpm install`. + */ +import { readFile, writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline"; +import { decryptArchive } from "@/lib/export/passphrase-archive"; + +function usage(): never { + console.error( + "Usage: pnpm dlx tsx scripts/decrypt-export.ts [output.json|-]", + ); + process.exit(2); +} + +/** Read a line from the TTY with local echo disabled. */ +async function promptPassphrase(): Promise { + const fromEnv = process.env.EXPORT_PASSPHRASE; + if (fromEnv && fromEnv.length > 0) return fromEnv; + + if (!process.stdin.isTTY) { + console.error( + "No TTY available and EXPORT_PASSPHRASE is unset — cannot read passphrase.", + ); + process.exit(2); + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + // Mute echo: intercept the output stream while the prompt is active. + const out = process.stdout; + let muted = false; + const realWrite = out.write.bind(out); + (out as unknown as { write: typeof out.write }).write = (( + chunk: string | Uint8Array, + ...rest: unknown[] + ) => { + if (muted) return true; + // @ts-expect-error variadic passthrough + return realWrite(chunk, ...rest); + }) as typeof out.write; + + process.stdout.write("Passphrase: "); + muted = true; + try { + const answer = await new Promise((resolve) => { + rl.question("", resolve); + }); + return answer; + } finally { + muted = false; + (out as unknown as { write: typeof out.write }).write = realWrite; + process.stdout.write("\n"); + rl.close(); + } +} + +async function main(): Promise { + const input = process.argv[2]; + if (!input) usage(); + const output = process.argv[3] ?? input.replace(/\.hlx$/i, "") + ".json"; + + const buf = await readFile(input); + const passphrase = await promptPassphrase(); + + let plaintext: string; + try { + plaintext = await decryptArchive(buf, passphrase); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + + if (output === "-") { + process.stdout.write(plaintext); + if (!plaintext.endsWith("\n")) process.stdout.write("\n"); + } else { + await writeFile(output, plaintext, "utf8"); + console.error(`Decrypted -> ${output}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts new file mode 100644 index 000000000..970e10616 --- /dev/null +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -0,0 +1,116 @@ +/** + * v1.23 — POST /api/export/encrypted unit coverage. + * + * Mock-based. Pins the step-up gating contract (single-factor accounts export + * without step-up; MFA accounts need a fresh second factor) and that the + * passphrase produces a decryptable HLX1 archive — never a plaintext body. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + session: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/geo", () => ({ + lookupIpLocation: vi.fn(), + lookupIpAsn: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { POST } from "../route"; +import { decryptArchive } from "@/lib/export/passphrase-archive"; + +const PASSPHRASE = "a-strong-passphrase-123"; + +function mkReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/export/encrypted", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + reset: Date.now() + 3600_000, + } as never); +}); + +describe("POST /api/export/encrypted", () => { + it("returns a decryptable HLX1 archive for a single-factor account", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: null }, + session: { id: "sess-1" }, + } as never); + + const res = await POST(mkReq({ passphrase: PASSPHRASE })); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/octet-stream"); + + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf.subarray(0, 4).toString("ascii")).toBe("HLX1"); + const plaintext = await decryptArchive(buf, PASSPHRASE); + const payload = JSON.parse(plaintext); + expect(payload).toHaveProperty("schemaVersion"); + expect(payload).toHaveProperty("measurements"); + }); + + it("blocks an MFA account without a fresh second factor (401 step-up)", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() }, + session: { id: "sess-1" }, + } as never); + // No fresh mfaVerifiedAt on the session row. + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: null, + } as never); + + const res = await POST(mkReq({ passphrase: PASSPHRASE })); + expect(res.status).toBe(401); + }); + + it("allows an MFA account with a fresh second factor", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() }, + session: { id: "sess-1" }, + } as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(), + } as never); + + const res = await POST(mkReq({ passphrase: PASSPHRASE })); + expect(res.status).toBe(200); + const buf = Buffer.from(await res.arrayBuffer()); + expect(await decryptArchive(buf, PASSPHRASE)).toContain("schemaVersion"); + }); + + it("rejects a too-short passphrase with 422", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: null }, + session: { id: "sess-1" }, + } as never); + + const res = await POST(mkReq({ passphrase: "short" })); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts new file mode 100644 index 000000000..f832f17ab --- /dev/null +++ b/src/app/api/export/encrypted/route.ts @@ -0,0 +1,127 @@ +/** + * POST /api/export/encrypted + * + * v1.23 — passphrase-encrypted variant of the full-backup export. Returns the + * same restore-compatible payload as `GET /api/export/full-backup`, but sealed + * into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase + * the caller supplies in the request body. The binary archive is returned as + * `application/octet-stream`. + * + * SECURITY: + * - Step-up: when the account has a second factor enrolled, this is gated by + * `requireFreshMfa` — exporting the whole record is a sensitive action. + * Single-factor accounts fall back to a normal authenticated session. + * - The passphrase NEVER hits a log or wide-event: it is read off the parsed + * body, passed straight into the KDF, and never `annotate()`d. The egress + * redaction denylist already scrubs `/passphrase/i` (key-name) and the + * `passphrase=` query-string form as defence in depth. + * - There is NO server-side recovery — the passphrase is not stored. A + * forgotten passphrase means the archive is unrecoverable; the UI says so. + * + * Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh + * second factor for MFA accounts (Bearer can never satisfy step-up). + * Rate-limit: shared `export:` bucket (10/h) — same bucket as the + * plaintext export so the encrypted variant cannot be used to bypass the cap. + * Audit: `user.export.encrypted` with the row counts (never the passphrase). + */ +import { prisma } from "@/lib/db"; +import { z } from "zod/v4"; +import { + apiHandler, + requireAuth, + requireFreshMfa, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { + encryptArchive, + MIN_EXPORT_PASSPHRASE_LENGTH, +} from "@/lib/export/passphrase-archive"; +import { NextRequest, NextResponse } from "next/server"; + +const encryptedExportSchema = z + .object({ + passphrase: z + .string() + .min(MIN_EXPORT_PASSPHRASE_LENGTH) + // A generous upper bound — a passphrase, not a file. Keeps the KDF input + // bounded. + .max(1024), + }) + .strict(); + +export const POST = apiHandler(async (request: NextRequest) => { + // Resolve the session first (cookie or Bearer), then escalate to a fresh + // second factor when the account has one enrolled. A single-factor account + // cannot produce a fresh-MFA proof, so gating it unconditionally would lock + // it out of its own encrypted export — the step-up applies to MFA users. + const auth = await requireAuth(); + if (auth.user.totpConfirmedAt) { + await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + } + const user = auth.user; + annotate({ action: { name: "user.export.encrypted" } }); + + const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximum 10 exports per hour", 429); + } + + // The body is a single passphrase string — small and bounded. 8 KB is far + // above any legitimate passphrase while rejecting a large body before parse. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = encryptedExportSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error); + } + const { passphrase } = parsed.data; + + const { payload, counts } = await buildFullBackupPayload(prisma, user.id); + + // Encrypt the JSON bytes. The passphrase goes no further than the KDF. + const archive = await encryptArchive(JSON.stringify(payload), passphrase); + + await auditLog("user.export.encrypted", { + userId: user.id, + ipAddress: getClientIp(request), + details: { counts, format: "HLX1" }, + }); + + annotate({ + meta: { + export_measurements_count: counts.measurements, + export_medications_count: counts.medications, + export_intake_count: counts.intakeEvents, + export_mood_count: counts.moodEntries, + export_cycle_count: counts.cycles, + export_cycle_day_log_count: counts.cycleDayLogs, + export_archive_bytes: archive.byteLength, + }, + }); + + const stamp = new Date().toISOString().slice(0, 10); + // Return the raw binary archive (NOT the apiSuccess envelope). The file is a + // self-contained `.hlx` archive openable with the user's passphrase via + // scripts/decrypt-export.ts. + return new NextResponse(new Uint8Array(archive), { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.hlx"`, + "Cache-Control": "no-store", + }, + }); +}); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index 16c8a85b6..aad5fd565 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -21,9 +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 { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { NextRequest, NextResponse } from "next/server"; export const GET = apiHandler(async (request: NextRequest) => { @@ -35,125 +33,24 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiError("Maximum 10 exports per hour", 429); } - const [measurements, medications, intakeEvents, moodEntries, cycle] = - await Promise.all([ - prisma.measurement.findMany({ - // v1.4.41 W-DELETED-2 — soft-deleted rows are excluded from the - // user-initiated backup bundle so the round-trip via admin - // restore does not resurrect deleted measurements. - where: { userId: user.id, deletedAt: null }, - orderBy: { measuredAt: "desc" }, - }), - // Explicit `select` on the schedule columns — see the matching - // comment in `medications/route.ts` for the rationale (the - // integration testbed has an unmigrated `days_of_week` column on - // the schema branch we ship alongside). - prisma.medication.findMany({ - where: { userId: user.id }, - select: { - name: true, - dose: true, - active: true, - schedules: { - select: { - windowStart: true, - windowEnd: true, - label: true, - dose: true, - }, - }, - }, - }), - prisma.medicationIntakeEvent.findMany({ - // v1.7.0 sync — exclude tombstoned rows (mirrors the measurement - // `deletedAt: null` filter above on this user-facing export). - where: { userId: user.id, deletedAt: null }, - include: { medication: { select: { name: true } } }, - orderBy: { scheduledFor: "desc" }, - }), - prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows. - where: { userId: user.id, deletedAt: null }, - orderBy: { moodLoggedAt: "desc" }, - }), - // v1.15.0 — cycle tables (profile + observed spans + day-logs). The - // shared helper carries `notesEncrypted` verbatim (never decrypted). - buildCycleBackupSection(prisma, user.id), - ]); - - // Shape mirrors the pg-boss `data-backup` worker exactly so the same - // `parseBackupPayload()` validator round-trips both blobs. Keep these - // two writers in sync — `src/lib/jobs/reminder-worker.ts` is the - // canonical reference. - const payload = { - schemaVersion: BACKUP_SCHEMA_VERSION, - exportedAt: new Date().toISOString(), - userId: user.id, - measurements: measurements.map((m) => ({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt.toISOString(), - source: m.source, - // 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, - dose: m.dose, - active: m.active, - schedules: m.schedules.map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label, - dose: s.dose, - })), - })), - intakeEvents: intakeEvents.map((e) => ({ - medication: e.medication.name, - scheduledFor: e.scheduledFor.toISOString(), - takenAt: e.takenAt?.toISOString() ?? null, - skipped: e.skipped, - source: e.source, - })), - moodEntries: moodEntries.map((e) => ({ - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags, - source: e.source, - loggedAt: e.moodLoggedAt.toISOString(), - })), - // v1.15.0 — cycle slice (profile + observed spans + day-logs). - cycleProfile: cycle.cycleProfile, - cycles: cycle.cycles, - cycleDayLogs: cycle.cycleDayLogs, - }; + // v1.23 — the payload builder is shared with the passphrase-encrypted + // export route so both emit the byte-for-byte same restore-compatible shape. + const { payload, counts } = await buildFullBackupPayload(prisma, user.id); await auditLog("user.export.full-backup", { userId: user.id, ipAddress: getClientIp(request), - details: { - counts: { - measurements: measurements.length, - medications: medications.length, - intakeEvents: intakeEvents.length, - moodEntries: moodEntries.length, - cycles: cycle.cycles.length, - cycleDayLogs: cycle.cycleDayLogs.length, - }, - }, + details: { counts }, }); annotate({ meta: { - export_measurements_count: measurements.length, - export_medications_count: medications.length, - export_intake_count: intakeEvents.length, - export_mood_count: moodEntries.length, - export_cycle_count: cycle.cycles.length, - export_cycle_day_log_count: cycle.cycleDayLogs.length, + export_measurements_count: counts.measurements, + export_medications_count: counts.medications, + export_intake_count: counts.intakeEvents, + export_mood_count: counts.moodEntries, + export_cycle_count: counts.cycles, + export_cycle_day_log_count: counts.cycleDayLogs, }, }); diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 7078532a6..08582fbb0 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -38,6 +38,7 @@ import { import { Button } from "@/components/ui/button"; import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; import { Switch } from "@/components/ui/switch"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -412,20 +413,55 @@ function MoodCsvCard() { ); } +const MIN_EXPORT_PASSPHRASE_LENGTH = 12; + function FullBackupCard() { const { t } = useTranslations(); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // v1.23 — opt-in passphrase encryption. When on, the download POSTs to + // /api/export/encrypted with the passphrase and saves an opaque `.hlx` + // archive instead of plaintext JSON. + const [encrypt, setEncrypt] = useState(false); + const [passphrase, setPassphrase] = useState(""); + + const passphraseTooShort = + encrypt && passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH; async function handleDownload() { setBusy(true); setError(null); try { const stamp = new Date().toISOString().slice(0, 10); - await downloadFromUrl( - "/api/export/full-backup", - `healthlog-backup-${stamp}.json`, - ); + if (encrypt) { + const res = await apiFetchRaw("/api/export/encrypted", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase }), + }); + if (res.status === 401) { + setError(t("settings.sections.export.cards.fullBackup.stepUpNeeded")); + return; + } + if (!res.ok) { + throw new Error(`Download failed (${res.status})`); + } + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objectUrl; + a.download = `healthlog-backup-${stamp}.hlx`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(objectUrl); + } else { + await downloadFromUrl( + "/api/export/full-backup", + `healthlog-backup-${stamp}.json`, + ); + } } catch { setError(t("settings.sections.export.downloadFailed")); } finally { @@ -447,7 +483,7 @@ function FullBackupCard() { size="sm" className="min-h-11 sm:min-h-9" onClick={handleDownload} - disabled={busy} + disabled={busy || passphraseTooShort} > {busy ? ( @@ -458,6 +494,44 @@ function FullBackupCard() { } > + + {encrypt && ( +
+ setPassphrase(e.target.value)} + placeholder={t( + "settings.sections.export.cards.fullBackup.passphrasePlaceholder", + )} + autoComplete="new-password" + className="h-9 text-sm" + /> +

+ {t("settings.sections.export.cards.fullBackup.passphraseHelp")} +

+

+ {t("settings.sections.export.cards.fullBackup.noRecoveryWarning")} +

+
+ )} {error && (

{error} diff --git a/src/lib/export/__tests__/passphrase-archive.test.ts b/src/lib/export/__tests__/passphrase-archive.test.ts new file mode 100644 index 000000000..2cd158c97 --- /dev/null +++ b/src/lib/export/__tests__/passphrase-archive.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { Buffer } from "node:buffer"; +import { + encryptArchive, + decryptArchive, + parseArchiveHeader, + EXPORT_ARGON2_PARAMS, + MIN_EXPORT_PASSPHRASE_LENGTH, +} from "../passphrase-archive"; + +const PASSPHRASE = "correct horse battery staple"; +const PAYLOAD = JSON.stringify({ + schemaVersion: 1, + measurements: [{ type: "WEIGHT", value: 81.2, unit: "kg" }], + note: "Zugangsdaten 🔐 € ñ", +}); + +describe("passphrase-encrypted export archive (HLX1)", () => { + it("round-trips encrypt -> decrypt with the right passphrase", async () => { + const archive = await encryptArchive(PAYLOAD, PASSPHRASE); + const out = await decryptArchive(archive, PASSPHRASE); + expect(out).toBe(PAYLOAD); + }); + + it("emits the HLX1 magic + version header", async () => { + const archive = await encryptArchive(PAYLOAD, PASSPHRASE); + expect(archive.subarray(0, 4).toString("ascii")).toBe("HLX1"); + expect(archive.readUInt8(4)).toBe(0x01); // version + expect(archive.readUInt8(5)).toBe(0x01); // KDF = Argon2id + }); + + it("carries the Argon2id KDF params + salt in the header", async () => { + const archive = await encryptArchive(PAYLOAD, PASSPHRASE); + const { header, bodyOffset } = parseArchiveHeader(archive); + expect(header.memoryCost).toBe(EXPORT_ARGON2_PARAMS.memoryCost); + expect(header.timeCost).toBe(EXPORT_ARGON2_PARAMS.timeCost); + expect(header.parallelism).toBe(EXPORT_ARGON2_PARAMS.parallelism); + expect(header.salt.length).toBe(16); + expect(bodyOffset).toBe(16 + 16 + 12 + 16); // header + salt + iv + tag + }); + + it("uses a random salt + iv so two archives of the same input differ", async () => { + const a = await encryptArchive(PAYLOAD, PASSPHRASE); + const b = await encryptArchive(PAYLOAD, PASSPHRASE); + expect(Buffer.compare(a, b)).not.toBe(0); + // ...but both decrypt back to the same plaintext. + expect(await decryptArchive(a, PASSPHRASE)).toBe(PAYLOAD); + expect(await decryptArchive(b, PASSPHRASE)).toBe(PAYLOAD); + }); + + it("fails cleanly with a wrong passphrase (no plaintext leak)", async () => { + const archive = await encryptArchive(PAYLOAD, PASSPHRASE); + await expect( + decryptArchive(archive, "totally wrong passphrase"), + ).rejects.toThrow(/wrong passphrase or corrupt archive/i); + }); + + it("fails when the ciphertext is tampered", async () => { + const archive = await encryptArchive(PAYLOAD, PASSPHRASE); + const tampered = Buffer.from(archive); + tampered[tampered.length - 1] ^= 0xff; // flip a ciphertext bit + await expect(decryptArchive(tampered, PASSPHRASE)).rejects.toThrow(); + }); + + it("rejects a passphrase shorter than the minimum on encrypt", async () => { + await expect(encryptArchive(PAYLOAD, "short")).rejects.toThrow( + new RegExp(`at least ${MIN_EXPORT_PASSPHRASE_LENGTH}`), + ); + }); + + it("rejects a non-HLX1 buffer", () => { + expect(() => parseArchiveHeader(Buffer.from("not an archive"))).toThrow(); + }); + + it("accepts a Buffer payload, not only a string", async () => { + const buf = Buffer.from(PAYLOAD, "utf8"); + const archive = await encryptArchive(buf, PASSPHRASE); + expect(await decryptArchive(archive, PASSPHRASE)).toBe(PAYLOAD); + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts new file mode 100644 index 000000000..27357f1e6 --- /dev/null +++ b/src/lib/export/full-backup-payload.ts @@ -0,0 +1,128 @@ +/** + * v1.23 — shared builder for the user-scoped full-backup payload. + * + * Extracted so both the plaintext `GET /api/export/full-backup` route and the + * passphrase-encrypted `POST /api/export/encrypted` route emit the byte-for-byte + * same shape — the one that the pg-boss `data-backup` worker writes and that + * `parseBackupPayload()` round-trips on admin restore. Keep this writer in sync + * with the `data-backup` worker (`src/lib/jobs/reminder-worker.ts`). + * + * The decrypted notes are surfaced here (the backup is the human-readable + * artefact); an admin restore re-encrypts them on re-insert. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { readNote } from "@/lib/crypto/note-cipher"; +import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; +import { buildCycleBackupSection } from "@/lib/cycle/backup"; + +export interface FullBackupCounts { + measurements: number; + medications: number; + intakeEvents: number; + moodEntries: number; + cycles: number; + cycleDayLogs: number; +} + +export interface FullBackupResult { + payload: Record; + counts: FullBackupCounts; +} + +/** + * Build the canonical full-backup payload for `userId`. Soft-deleted rows are + * excluded so a round-trip via admin restore never resurrects deleted records. + */ +export async function buildFullBackupPayload( + prisma: PrismaClient, + userId: string, +): Promise { + const [measurements, medications, intakeEvents, moodEntries, cycle] = + await Promise.all([ + prisma.measurement.findMany({ + where: { userId, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + }), + prisma.medication.findMany({ + where: { userId }, + select: { + name: true, + dose: true, + active: true, + schedules: { + select: { + windowStart: true, + windowEnd: true, + label: true, + dose: true, + }, + }, + }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { userId, deletedAt: null }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "desc" }, + }), + prisma.moodEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + }), + buildCycleBackupSection(prisma, userId), + ]); + + const payload = { + schemaVersion: BACKUP_SCHEMA_VERSION, + exportedAt: new Date().toISOString(), + userId, + measurements: measurements.map((m) => ({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt.toISOString(), + source: m.source, + notes: readNote(m.notesEncrypted, m.notes), + })), + medications: medications.map((m) => ({ + name: m.name, + dose: m.dose, + active: m.active, + schedules: m.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label, + dose: s.dose, + })), + })), + intakeEvents: intakeEvents.map((e) => ({ + medication: e.medication.name, + scheduledFor: e.scheduledFor.toISOString(), + takenAt: e.takenAt?.toISOString() ?? null, + skipped: e.skipped, + source: e.source, + })), + moodEntries: moodEntries.map((e) => ({ + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags, + source: e.source, + loggedAt: e.moodLoggedAt.toISOString(), + })), + cycleProfile: cycle.cycleProfile, + cycles: cycle.cycles, + cycleDayLogs: cycle.cycleDayLogs, + }; + + return { + payload, + counts: { + measurements: measurements.length, + medications: medications.length, + intakeEvents: intakeEvents.length, + moodEntries: moodEntries.length, + cycles: cycle.cycles.length, + cycleDayLogs: cycle.cycleDayLogs.length, + }, + }; +} diff --git a/src/lib/export/passphrase-archive.ts b/src/lib/export/passphrase-archive.ts new file mode 100644 index 000000000..e30ee171b --- /dev/null +++ b/src/lib/export/passphrase-archive.ts @@ -0,0 +1,227 @@ +/** + * v1.23 — passphrase-encrypted export archive (`HLX1` wire format). + * + * A user can choose to download their export as an encrypted archive instead + * of plaintext JSON. The passphrase the user supplies is run through Argon2id + * to derive a 256-bit key, and the export bytes are sealed with AES-256-GCM. + * + * THREAT MODEL. The data is already plaintext-at-rest on the server (the + * server computes insights / rollups / FHIR over it). What this closes is the + * "plaintext health file lands in Downloads / iCloud / Google-Drive sync" + * path: the file that leaves the server is opaque without the passphrase. + * There is NO server-side recovery — the passphrase is never stored and never + * logged, so a forgotten passphrase means the archive is unrecoverable. The + * UI states this loudly. + * + * Wire format (binary), mirroring the off-host backup `HLBK` envelope so there + * is one decryption mental model in the codebase + docs: + * + * magic(4) = "HLX1" + * version(1) = 0x01 + * kdf(1) = 0x01 (Argon2id) + * memoryCost(4) = uint32 BE (KiB; Argon2 `m`) + * timeCost(4) = uint32 BE (Argon2 `t`) + * parallelism(1)= uint8 (Argon2 `p`) + * saltLen(1) = uint8 (= 16) + * salt(saltLen) + * iv(12) (AES-GCM nonce) + * tag(16) (AES-GCM auth tag) + * ciphertext(...) + * + * The Argon2 params travel in the header (self-describing) so a future cost + * bump still decrypts every previously-written archive — the decryptor reads + * `m/t/p` + `salt` from the file, never from a constant. The standalone + * `scripts/decrypt-export.ts` decryptor depends ONLY on this module, so a user + * can open the archive with `pnpm dlx tsx` and their passphrase, app or not. + * + * KDF defaults are the 2025 OWASP minimum for Argon2id (RFC 9106): + * 19 MiB / t=2 / p=1, 32-byte output. Kept independent from the password + * hashing params so tuning one never silently changes the other. + */ +import { hashRaw, type Algorithm } from "@node-rs/argon2"; +import { + createCipheriv, + createDecipheriv, + randomBytes, + timingSafeEqual, +} from "node:crypto"; + +const MAGIC = "HLX1"; +const VERSION = 0x01; +const KDF_ARGON2ID = 0x01; +// `Algorithm.Argon2id` is a const enum (= 2); reference the value directly so +// the module compiles under `isolatedModules` without importing the enum. +const ARGON2ID: Algorithm = 2 as Algorithm; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const TAG_LENGTH = 16; +const SALT_LENGTH = 16; +const KEY_LENGTH = 32; + +/** + * Argon2id KDF parameters for newly-written archives. OWASP-minimum for + * 2025 (RFC 9106): 19 MiB memory, 2 passes, single lane, 32-byte key. + * These are written into each archive header, so changing them never + * orphans archives written under the old cost. + */ +export const EXPORT_ARGON2_PARAMS = { + /** Argon2 `m` — memory cost in KiB (19 MiB). */ + memoryCost: 19456, + /** Argon2 `t` — iterations. */ + timeCost: 2, + /** Argon2 `p` — lanes. */ + parallelism: 1, +} as const; + +/** + * Minimum passphrase length the encrypt path enforces. A short passphrase + * defeats the KDF; 12 mirrors the account password floor. + */ +export const MIN_EXPORT_PASSPHRASE_LENGTH = 12; + +export interface ArchiveHeader { + version: number; + kdf: number; + memoryCost: number; + timeCost: number; + parallelism: number; + salt: Buffer; +} + +/** Derive the 256-bit AES key from a passphrase + the archive's KDF params. */ +async function deriveKey( + passphrase: string, + params: { memoryCost: number; timeCost: number; parallelism: number }, + salt: Buffer, +): Promise { + return hashRaw(passphrase, { + algorithm: ARGON2ID, + memoryCost: params.memoryCost, + timeCost: params.timeCost, + parallelism: params.parallelism, + outputLen: KEY_LENGTH, + salt, + }); +} + +/** + * Encrypt `plaintext` into an `HLX1` archive sealed with `passphrase`. + * Throws if the passphrase is shorter than `MIN_EXPORT_PASSPHRASE_LENGTH`. + */ +export async function encryptArchive( + plaintext: string | Buffer, + passphrase: string, +): Promise { + if (passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) { + throw new Error( + `Passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters`, + ); + } + + const salt = randomBytes(SALT_LENGTH); + const key = await deriveKey(passphrase, EXPORT_ARGON2_PARAMS, salt); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const body = + typeof plaintext === "string" ? Buffer.from(plaintext, "utf8") : plaintext; + const ciphertext = Buffer.concat([cipher.update(body), cipher.final()]); + const tag = cipher.getAuthTag(); + + const header = Buffer.alloc(4 + 1 + 1 + 4 + 4 + 1 + 1); + header.write(MAGIC, 0, "ascii"); + header.writeUInt8(VERSION, 4); + header.writeUInt8(KDF_ARGON2ID, 5); + header.writeUInt32BE(EXPORT_ARGON2_PARAMS.memoryCost, 6); + header.writeUInt32BE(EXPORT_ARGON2_PARAMS.timeCost, 10); + header.writeUInt8(EXPORT_ARGON2_PARAMS.parallelism, 14); + header.writeUInt8(SALT_LENGTH, 15); + + return Buffer.concat([header, salt, iv, tag, ciphertext]); +} + +/** Parse + validate the fixed `HLX1` header. Throws on any structural fault. */ +export function parseArchiveHeader(buf: Buffer): { + header: ArchiveHeader; + bodyOffset: number; +} { + if (buf.length < 16) { + throw new Error("Not an HLX1 archive (too short)"); + } + const magic = buf.subarray(0, 4).toString("ascii"); + if (!timingSafeEqual(Buffer.from(magic), Buffer.from(MAGIC))) { + throw new Error("Not an HLX1 archive (bad magic)"); + } + const version = buf.readUInt8(4); + if (version !== VERSION) { + throw new Error(`Unsupported HLX1 version ${version}`); + } + const kdf = buf.readUInt8(5); + if (kdf !== KDF_ARGON2ID) { + throw new Error(`Unsupported KDF id ${kdf}`); + } + const memoryCost = buf.readUInt32BE(6); + const timeCost = buf.readUInt32BE(10); + const parallelism = buf.readUInt8(14); + const saltLen = buf.readUInt8(15); + const saltStart = 16; + const ivStart = saltStart + saltLen; + const tagStart = ivStart + IV_LENGTH; + const bodyOffset = tagStart + TAG_LENGTH; + if (buf.length < bodyOffset) { + throw new Error("Corrupt HLX1 archive (truncated header)"); + } + if (memoryCost < 1 || timeCost < 1 || parallelism < 1 || saltLen < 8) { + throw new Error("Corrupt HLX1 archive (implausible KDF params)"); + } + return { + header: { + version, + kdf, + memoryCost, + timeCost, + parallelism, + salt: buf.subarray(saltStart, ivStart), + }, + bodyOffset, + }; +} + +/** + * Decrypt an `HLX1` archive with `passphrase`. A wrong passphrase fails the + * GCM authentication tag and throws a clean, generic error (no oracle about + * which part was wrong). The returned value is the original plaintext (UTF-8). + */ +export async function decryptArchive( + buf: Buffer, + passphrase: string, +): Promise { + const { header, bodyOffset } = parseArchiveHeader(buf); + const saltLen = header.salt.length; + const ivStart = 16 + saltLen; + const tagStart = ivStart + IV_LENGTH; + const iv = buf.subarray(ivStart, tagStart); + const tag = buf.subarray(tagStart, bodyOffset); + const ciphertext = buf.subarray(bodyOffset); + + const key = await deriveKey( + passphrase, + { + memoryCost: header.memoryCost, + timeCost: header.timeCost, + parallelism: header.parallelism, + }, + Buffer.from(header.salt), + ); + + try { + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + const out = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return out.toString("utf8"); + } catch { + // GCM tag mismatch — wrong passphrase or a tampered/corrupt archive. + // Deliberately opaque: never reveal which. + throw new Error("Decryption failed — wrong passphrase or corrupt archive"); + } +} diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts new file mode 100644 index 000000000..ecee44185 --- /dev/null +++ b/src/lib/openapi/routes/export.ts @@ -0,0 +1,51 @@ +/** + * OpenAPI route table — generic data export (passphrase-encrypted variant). + * + * The plaintext `GET /api/export/full-backup` + per-type CSV routes predate the + * OpenAPI registry and are not iOS-contract surfaces; this module documents the + * v1.23 passphrase-encrypted variant because it adds a request flag + a binary + * response shape the iOS client may consume. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { stdResponses } from "./shared"; + +const encryptedExportRequest = z + .object({ + passphrase: z.string().min(12).max(1024).meta({ + description: + "User-chosen passphrase (>= 12 chars). Derives the archive key via Argon2id; never stored, never logged, no server-side recovery.", + }), + }) + .meta({ id: "EncryptedExportRequest" }); + +export const exportPaths: NonNullable = { + "/api/export/encrypted": { + post: { + tags: ["Export"], + summary: "Download the full backup as a passphrase-encrypted archive", + description: + "v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`, cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal session or Bearer. Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable.", + requestBody: { + required: true, + content: { + "application/json": { schema: encryptedExportRequest }, + }, + }, + responses: { + "200": { + description: + "Encrypted archive. `application/octet-stream`; the body is the `HLX1` binary (magic | version | Argon2 params | salt | iv | tag | ciphertext).", + content: { + "application/octet-stream": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index b4b5a9c91..a57314ece 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -34,6 +34,7 @@ import { consentPaths } from "./consent"; import { cyclePaths } from "./cycle"; import { biomarkerPaths } from "./biomarkers"; import { devicePaths } from "./devices"; +import { exportPaths } from "./export"; import { healthRecordPaths } from "./health-record"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; @@ -55,6 +56,7 @@ export const openApiPaths: NonNullable = { ...cyclePaths, ...metaPaths, ...healthRecordPaths, + ...exportPaths, ...authPaths, ...syncPaths, ...measurementPaths, From e5f9fb7c4a068fa138172ed0960b7825700318a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:27:31 +0200 Subject: [PATCH 08/34] feat(admin): show encryption coverage and rotation progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a read-only admin Encryption section that surfaces what the rotation tooling computes: per encrypted-column rows-per-key-id, legacy-key row counts, and overall rotation progress, plus a 'safe to drop the legacy key' state once every column sits on the active key. GET /api/admin/encryption/status drives it and returns only key ids and counts — never key material. A shared scan over the canonical encrypted- column registry backs both this view and the rotation run. Cookie-only admin. --- messages/de.json | 39 +++ messages/en.json | 39 +++ messages/es.json | 39 +++ messages/fr.json | 39 +++ messages/it.json | 39 +++ messages/pl.json | 39 +++ src/app/admin/[section]/renderer.tsx | 10 + src/app/api/admin/encryption/status/route.ts | 137 ++++++++ src/components/admin/admin-shell.tsx | 7 + src/components/admin/encryption-section.tsx | 295 ++++++++++++++++++ src/components/admin/section-slugs.ts | 4 + src/lib/crypto.ts | 10 + .../__tests__/encryption-corpus.test.ts | 205 ++++++++++++ src/lib/crypto/encryption-corpus.ts | 260 +++++++++++++++ src/lib/query-keys/admin.ts | 2 + 15 files changed, 1164 insertions(+) create mode 100644 src/app/api/admin/encryption/status/route.ts create mode 100644 src/components/admin/encryption-section.tsx create mode 100644 src/lib/crypto/__tests__/encryption-corpus.test.ts create mode 100644 src/lib/crypto/encryption-corpus.ts diff --git a/messages/de.json b/messages/de.json index 99c4d6007..7f5ecbdcd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5526,6 +5526,45 @@ "module-availability": { "title": "Module", "subtitle": "Module für jedes Konto auf diesem Server aktivieren oder deaktivieren." + }, + "encryption": { + "title": "Verschlüsselung", + "subtitle": "Verschlüsselungsabdeckung und Schlüsselrotations-Status für ruhende Daten.", + "loading": "Verschlüsselungsstatus wird geladen...", + "loadError": "Verschlüsselungsstatus konnte nicht geladen werden.", + "coverageTitle": "Abdeckung", + "coverageDescription": "Wie viele verschlüsselte Zeilen unter welcher Schlüssel-ID liegen. Es werden nur Schlüssel-IDs und Anzahlen gezeigt — niemals Schlüsselmaterial.", + "activeKeyId": "Aktive Schlüssel-ID", + "configuredKeys": "Konfigurierte Schlüssel", + "totalRows": "Verschlüsselte Zeilen", + "staleRows": "Zeilen nicht auf dem aktiven Schlüssel", + "safeToDropLegacy": "Alter Schlüssel kann entfernt werden", + "rotationIncomplete": "{count} Zeilen brauchen noch eine Rotation", + "rotationTitle": "Schlüsselrotation", + "rotationDescription": "Jede verschlüsselte Spalte auf den aktiven Schlüssel neu verschlüsseln. Das Kommandozeilen-Werkzeug bleibt der kanonische Weg; dieser Auslöser ist eine Bequemlichkeit.", + "rotationState": "Status", + "lastRun": "Letzter Lauf: {scanned} geprüft, {rotated} rotiert, {errors} Fehler.", + "rotateNow": "Jetzt rotieren", + "rotateConfirmTitle": "Den gesamten Datensatz neu verschlüsseln?", + "rotateConfirmDescription": "Verschlüsselt jede verschlüsselte Spalte auf den aktiven Schlüssel neu. Es wird nie ein Schlüssel hinzugefügt oder entfernt, und ein erneuter Lauf ist sicher. Bei großen Konten kann das eine Weile dauern.", + "cancel": "Abbrechen", + "rotateConfirm": "Rotation starten", + "rotateEnqueued": "Rotation gestartet.", + "rotateFailed": "Rotation konnte nicht gestartet werden.", + "stepUpNeeded": "Ein frischer zweiter Faktor ist erforderlich. Bestätige deinen zweiten Faktor oder nutze das Kommandozeilen-Werkzeug, falls kein zweiter Faktor eingerichtet ist.", + "cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Betreiber ohne zweiten Faktor rotieren mit scripts/rotate-encryption-key.ts.", + "columnsTitle": "Abdeckung pro Spalte", + "columnsDescription": "Jede verschlüsselte Spalte mit ihren Zeilenzahlen auf dem aktiven Schlüssel gegenüber älteren Schlüsseln.", + "colColumn": "Spalte", + "colTotal": "Gesamt", + "colActive": "Auf aktivem Schlüssel", + "colStale": "Veraltet", + "state": { + "idle": "Bereit", + "running": "Läuft", + "completed": "Abgeschlossen", + "failed": "Fehlgeschlagen" + } } }, "carrier": "Anbieter", diff --git a/messages/en.json b/messages/en.json index c21dd63e9..ea832f414 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5526,6 +5526,45 @@ "module-availability": { "title": "Modules", "subtitle": "Turn modules on or off for every account on this server." + }, + "encryption": { + "title": "Encryption", + "subtitle": "Encryption coverage and key-rotation status for data at rest.", + "loading": "Loading encryption status...", + "loadError": "Failed to load encryption status.", + "coverageTitle": "Coverage", + "coverageDescription": "How many encrypted rows sit under each key id. Only key ids and counts are shown — never key material.", + "activeKeyId": "Active key id", + "configuredKeys": "Configured keys", + "totalRows": "Encrypted rows", + "staleRows": "Rows not on the active key", + "safeToDropLegacy": "Safe to drop the legacy key", + "rotationIncomplete": "{count} rows still need rotation", + "rotationTitle": "Key rotation", + "rotationDescription": "Re-encrypt every encrypted column to the active key. The command-line tool stays the canonical path; this trigger is a convenience.", + "rotationState": "State", + "lastRun": "Last run: scanned {scanned}, rotated {rotated}, errors {errors}.", + "rotateNow": "Rotate now", + "rotateConfirmTitle": "Re-encrypt the whole record?", + "rotateConfirmDescription": "Re-encrypts every encrypted column to the active key. It never adds or drops a key and is safe to re-run. This can take a while on large accounts.", + "cancel": "Cancel", + "rotateConfirm": "Start rotation", + "rotateEnqueued": "Rotation started.", + "rotateFailed": "Could not start the rotation.", + "stepUpNeeded": "A fresh second factor is required. Confirm your second factor, or use the command-line tool if you have no second factor enrolled.", + "cliNote": "A second factor is required to use this button. Operators without one rotate with scripts/rotate-encryption-key.ts.", + "columnsTitle": "Per-column coverage", + "columnsDescription": "Each encrypted column with its row counts on the active key versus older keys.", + "colColumn": "Column", + "colTotal": "Total", + "colActive": "On active key", + "colStale": "Stale", + "state": { + "idle": "Idle", + "running": "Running", + "completed": "Completed", + "failed": "Failed" + } } }, "carrier": "Carrier", diff --git a/messages/es.json b/messages/es.json index bed491e0e..2f8e83128 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5526,6 +5526,45 @@ "module-availability": { "title": "Módulos", "subtitle": "Activa o desactiva módulos para todas las cuentas de este servidor." + }, + "encryption": { + "title": "Cifrado", + "subtitle": "Cobertura de cifrado y estado de rotación de claves para los datos en reposo.", + "loading": "Cargando el estado de cifrado...", + "loadError": "No se pudo cargar el estado de cifrado.", + "coverageTitle": "Cobertura", + "coverageDescription": "Cuántas filas cifradas hay bajo cada id de clave. Solo se muestran los id de clave y los recuentos, nunca el material de la clave.", + "activeKeyId": "Id de clave activa", + "configuredKeys": "Claves configuradas", + "totalRows": "Filas cifradas", + "staleRows": "Filas que no usan la clave activa", + "safeToDropLegacy": "Es seguro eliminar la clave antigua", + "rotationIncomplete": "Aún quedan {count} filas por rotar", + "rotationTitle": "Rotación de claves", + "rotationDescription": "Vuelve a cifrar cada columna cifrada con la clave activa. La herramienta de línea de comandos sigue siendo la vía canónica; este disparador es una comodidad.", + "rotationState": "Estado", + "lastRun": "Última ejecución: {scanned} revisadas, {rotated} rotadas, {errors} errores.", + "rotateNow": "Rotar ahora", + "rotateConfirmTitle": "¿Volver a cifrar todo el registro?", + "rotateConfirmDescription": "Vuelve a cifrar cada columna cifrada con la clave activa. Nunca añade ni elimina una clave y se puede repetir con seguridad. En cuentas grandes puede tardar un rato.", + "cancel": "Cancelar", + "rotateConfirm": "Iniciar rotación", + "rotateEnqueued": "Rotación iniciada.", + "rotateFailed": "No se pudo iniciar la rotación.", + "stepUpNeeded": "Se requiere un segundo factor reciente. Confirma tu segundo factor o usa la herramienta de línea de comandos si no tienes ninguno configurado.", + "cliNote": "Se necesita un segundo factor para usar este botón. Los operadores sin él rotan con scripts/rotate-encryption-key.ts.", + "columnsTitle": "Cobertura por columna", + "columnsDescription": "Cada columna cifrada con sus recuentos de filas en la clave activa frente a claves anteriores.", + "colColumn": "Columna", + "colTotal": "Total", + "colActive": "En clave activa", + "colStale": "Obsoletas", + "state": { + "idle": "Inactivo", + "running": "En curso", + "completed": "Completado", + "failed": "Fallido" + } } }, "carrier": "Operador", diff --git a/messages/fr.json b/messages/fr.json index e4f6a672f..cf4d07338 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5526,6 +5526,45 @@ "module-availability": { "title": "Modules", "subtitle": "Activez ou désactivez les modules pour tous les comptes de ce serveur." + }, + "encryption": { + "title": "Chiffrement", + "subtitle": "Couverture du chiffrement et état de la rotation des clés pour les données au repos.", + "loading": "Chargement de l'état du chiffrement...", + "loadError": "Échec du chargement de l'état du chiffrement.", + "coverageTitle": "Couverture", + "coverageDescription": "Combien de lignes chiffrées relèvent de chaque id de clé. Seuls les id de clé et les décomptes sont affichés, jamais le matériel de clé.", + "activeKeyId": "Id de clé active", + "configuredKeys": "Clés configurées", + "totalRows": "Lignes chiffrées", + "staleRows": "Lignes hors de la clé active", + "safeToDropLegacy": "La clé ancienne peut être retirée", + "rotationIncomplete": "{count} lignes restent à faire tourner", + "rotationTitle": "Rotation des clés", + "rotationDescription": "Re-chiffre chaque colonne chiffrée avec la clé active. L'outil en ligne de commande reste la voie canonique ; ce déclencheur est un confort.", + "rotationState": "État", + "lastRun": "Dernière exécution : {scanned} analysées, {rotated} tournées, {errors} erreurs.", + "rotateNow": "Faire tourner maintenant", + "rotateConfirmTitle": "Re-chiffrer tout le dossier ?", + "rotateConfirmDescription": "Re-chiffre chaque colonne chiffrée avec la clé active. Cela n'ajoute ni ne retire jamais de clé et peut être relancé sans risque. Cela peut prendre du temps sur les gros comptes.", + "cancel": "Annuler", + "rotateConfirm": "Démarrer la rotation", + "rotateEnqueued": "Rotation démarrée.", + "rotateFailed": "Impossible de démarrer la rotation.", + "stepUpNeeded": "Un second facteur récent est requis. Confirmez votre second facteur, ou utilisez l'outil en ligne de commande si aucun n'est configuré.", + "cliNote": "Un second facteur est requis pour ce bouton. Les opérateurs sans second facteur utilisent scripts/rotate-encryption-key.ts.", + "columnsTitle": "Couverture par colonne", + "columnsDescription": "Chaque colonne chiffrée avec ses décomptes de lignes sur la clé active par rapport aux clés plus anciennes.", + "colColumn": "Colonne", + "colTotal": "Total", + "colActive": "Sur clé active", + "colStale": "Obsolètes", + "state": { + "idle": "Au repos", + "running": "En cours", + "completed": "Terminé", + "failed": "Échoué" + } } }, "carrier": "Opérateur", diff --git a/messages/it.json b/messages/it.json index 702f0f4ff..e4d227f29 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5526,6 +5526,45 @@ "module-availability": { "title": "Moduli", "subtitle": "Attiva o disattiva i moduli per tutti gli account su questo server." + }, + "encryption": { + "title": "Crittografia", + "subtitle": "Copertura della crittografia e stato della rotazione delle chiavi per i dati a riposo.", + "loading": "Caricamento dello stato della crittografia...", + "loadError": "Impossibile caricare lo stato della crittografia.", + "coverageTitle": "Copertura", + "coverageDescription": "Quante righe cifrate stanno sotto ogni id di chiave. Vengono mostrati solo gli id di chiave e i conteggi, mai il materiale della chiave.", + "activeKeyId": "Id chiave attiva", + "configuredKeys": "Chiavi configurate", + "totalRows": "Righe cifrate", + "staleRows": "Righe non sulla chiave attiva", + "safeToDropLegacy": "È sicuro rimuovere la vecchia chiave", + "rotationIncomplete": "Restano {count} righe da ruotare", + "rotationTitle": "Rotazione delle chiavi", + "rotationDescription": "Ricifra ogni colonna cifrata con la chiave attiva. Lo strumento da riga di comando resta la via canonica; questo comando è una comodità.", + "rotationState": "Stato", + "lastRun": "Ultima esecuzione: {scanned} esaminate, {rotated} ruotate, {errors} errori.", + "rotateNow": "Ruota ora", + "rotateConfirmTitle": "Ricifrare l'intero archivio?", + "rotateConfirmDescription": "Ricifra ogni colonna cifrata con la chiave attiva. Non aggiunge né rimuove mai una chiave ed è sicuro rilanciarlo. Su account grandi può richiedere tempo.", + "cancel": "Annulla", + "rotateConfirm": "Avvia rotazione", + "rotateEnqueued": "Rotazione avviata.", + "rotateFailed": "Impossibile avviare la rotazione.", + "stepUpNeeded": "È richiesto un secondo fattore recente. Conferma il secondo fattore oppure usa lo strumento da riga di comando se non ne hai configurato uno.", + "cliNote": "Per questo pulsante serve un secondo fattore. Gli operatori senza secondo fattore ruotano con scripts/rotate-encryption-key.ts.", + "columnsTitle": "Copertura per colonna", + "columnsDescription": "Ogni colonna cifrata con i conteggi di righe sulla chiave attiva rispetto alle chiavi più vecchie.", + "colColumn": "Colonna", + "colTotal": "Totale", + "colActive": "Su chiave attiva", + "colStale": "Obsolete", + "state": { + "idle": "Inattivo", + "running": "In corso", + "completed": "Completato", + "failed": "Fallito" + } } }, "carrier": "Operatore", diff --git a/messages/pl.json b/messages/pl.json index 9f5463383..f4190da54 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5526,6 +5526,45 @@ "module-availability": { "title": "Moduły", "subtitle": "Włącz lub wyłącz moduły dla wszystkich kont na tym serwerze." + }, + "encryption": { + "title": "Szyfrowanie", + "subtitle": "Pokrycie szyfrowaniem i status rotacji kluczy dla danych w spoczynku.", + "loading": "Ładowanie statusu szyfrowania...", + "loadError": "Nie udało się wczytać statusu szyfrowania.", + "coverageTitle": "Pokrycie", + "coverageDescription": "Ile zaszyfrowanych wierszy przypada na każdy identyfikator klucza. Pokazywane są tylko identyfikatory kluczy i liczby — nigdy materiał klucza.", + "activeKeyId": "Identyfikator aktywnego klucza", + "configuredKeys": "Skonfigurowane klucze", + "totalRows": "Zaszyfrowane wiersze", + "staleRows": "Wiersze poza aktywnym kluczem", + "safeToDropLegacy": "Można bezpiecznie usunąć stary klucz", + "rotationIncomplete": "Pozostało {count} wierszy do rotacji", + "rotationTitle": "Rotacja kluczy", + "rotationDescription": "Ponownie szyfruje każdą zaszyfrowaną kolumnę aktywnym kluczem. Narzędzie wiersza poleceń pozostaje kanoniczną drogą; ten przycisk to udogodnienie.", + "rotationState": "Status", + "lastRun": "Ostatni przebieg: sprawdzono {scanned}, zrotowano {rotated}, błędy {errors}.", + "rotateNow": "Rotuj teraz", + "rotateConfirmTitle": "Ponownie zaszyfrować cały rekord?", + "rotateConfirmDescription": "Ponownie szyfruje każdą zaszyfrowaną kolumnę aktywnym kluczem. Nigdy nie dodaje ani nie usuwa klucza i można go bezpiecznie uruchomić ponownie. Na dużych kontach może to chwilę potrwać.", + "cancel": "Anuluj", + "rotateConfirm": "Rozpocznij rotację", + "rotateEnqueued": "Rotacja rozpoczęta.", + "rotateFailed": "Nie udało się rozpocząć rotacji.", + "stepUpNeeded": "Wymagany jest świeży drugi składnik. Potwierdź drugi składnik lub użyj narzędzia wiersza poleceń, jeśli nie masz skonfigurowanego żadnego.", + "cliNote": "Do tego przycisku wymagany jest drugi składnik. Operatorzy bez niego rotują przez scripts/rotate-encryption-key.ts.", + "columnsTitle": "Pokrycie wg kolumn", + "columnsDescription": "Każda zaszyfrowana kolumna z liczbą wierszy na aktywnym kluczu względem starszych kluczy.", + "colColumn": "Kolumna", + "colTotal": "Razem", + "colActive": "Na aktywnym kluczu", + "colStale": "Przestarzałe", + "state": { + "idle": "Bezczynny", + "running": "W toku", + "completed": "Zakończono", + "failed": "Niepowodzenie" + } } }, "carrier": "Operator", diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 4e87add9e..37a97d675 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -25,6 +25,7 @@ import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-s import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section"; import { BackupsSection } from "@/components/admin/backups-section"; import { DangerZoneSection } from "@/components/admin/danger-zone-section"; +import { EncryptionSection } from "@/components/admin/encryption-section"; import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section"; import { GeneralSettingsSection } from "@/components/admin/general-settings-section"; import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section"; @@ -195,6 +196,15 @@ export function AdminSectionRenderer({ ); + case "encryption": + return ( + + + + ); case "danger-zone": return ( { + const rows = await prisma.auditLog.findMany({ + where: { + action: { + in: [ + "admin.encryption.rotate.requested", + "admin.encryption.rotate.completed", + "admin.encryption.rotate.failed", + ], + }, + }, + orderBy: { createdAt: "desc" }, + take: 10, + select: { action: true, createdAt: true, details: true }, + }); + + const requested = rows.find( + (r) => r.action === "admin.encryption.rotate.requested", + ); + const finished = rows.find( + (r) => + r.action === "admin.encryption.rotate.completed" || + r.action === "admin.encryption.rotate.failed", + ); + + let lastResult: RotationState["lastResult"] = null; + if ( + finished?.action === "admin.encryption.rotate.completed" && + finished.details + ) { + try { + const d = JSON.parse(finished.details) as { + scanned?: number; + rotated?: number; + errors?: number; + }; + lastResult = { + scanned: d.scanned ?? 0, + rotated: d.rotated ?? 0, + errors: d.errors ?? 0, + }; + } catch { + lastResult = null; + } + } + + // "running" iff the most recent request is newer than the most recent + // completion/failure. + let state: RotationState["state"] = "idle"; + if (requested && (!finished || requested.createdAt > finished.createdAt)) { + state = "running"; + } else if (finished?.action === "admin.encryption.rotate.completed") { + state = "completed"; + } else if (finished?.action === "admin.encryption.rotate.failed") { + state = "failed"; + } + + return { + state, + lastRequestedAt: requested?.createdAt.toISOString() ?? null, + lastCompletedAt: finished?.createdAt.toISOString() ?? null, + lastResult, + }; +} + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.encryption.status" } }); + + const now = Date.now(); + let scan: CorpusScan; + if (scanCache && now - scanCache.at < SCAN_CACHE_TTL_MS) { + scan = scanCache.scan; + } else { + scan = await scanCorpus(prisma as unknown as CorpusClient); + scanCache = { at: now, scan }; + } + + const rotation = await readRotationState(); + + annotate({ + meta: { + encryption_total_rows: scan.totalRows, + encryption_stale_rows: scan.staleRows, + encryption_rotation_complete: scan.rotationComplete, + }, + }); + + return apiSuccess({ + activeKeyId: scan.activeKeyId, + // COUNT only — never the key material. + configuredKeyCount: getConfiguredKeyIds().length, + rotationComplete: scan.rotationComplete, + totalRows: scan.totalRows, + activeRows: scan.activeRows, + staleRows: scan.staleRows, + columns: scan.columns, + rotation, + }); +}); diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 46e52c8d6..79833c234 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -30,6 +30,7 @@ import { Server, Settings, ShieldAlert, + ShieldCheck, Sparkles, Ticket, Users, @@ -131,6 +132,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [ titleKey: "admin.section.backups.title", icon: Database, }, + // v1.23 — encryption coverage + key-rotation status / safe trigger. + { + slug: "encryption", + titleKey: "admin.section.encryption.title", + icon: ShieldCheck, + }, { slug: "danger-zone", titleKey: "admin.section.danger-zone.title", diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx new file mode 100644 index 000000000..8456d347f --- /dev/null +++ b/src/components/admin/encryption-section.tsx @@ -0,0 +1,295 @@ +"use client"; + +/** + * `` — admin view of encryption coverage + key-rotation + * status, with a guarded rotation trigger. + * + * Reads `GET /api/admin/encryption/status` (per-column rows-per-key-id, legacy + * counts, overall rotation progress). NEVER surfaces key material — only key + * ids (operator labels) and row counts. The rotation trigger POSTs to + * `/api/admin/encryption/rotate`, which is step-up gated server-side; a 401 + * here means the admin needs a fresh second factor (or has none enrolled, in + * which case the documented CLI is the path). + */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { KeyRound, Loader2, RotateCw, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { getApiErrorMessage } from "./_shared"; + +interface ColumnScan { + model: string; + field: string; + kind: "string" | "bytes"; + total: number; + byKeyId: Record; + legacy: number; +} + +interface EncryptionStatus { + activeKeyId: string; + configuredKeyCount: number; + rotationComplete: boolean; + totalRows: number; + activeRows: number; + staleRows: number; + columns: ColumnScan[]; + rotation: { + state: "idle" | "running" | "completed" | "failed"; + lastRequestedAt: string | null; + lastCompletedAt: string | null; + lastResult: { scanned: number; rotated: number; errors: number } | null; + }; +} + +export function EncryptionSection() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const statusQuery = useQuery({ + queryKey: queryKeys.adminEncryptionStatus(), + queryFn: async (): Promise => { + return apiGet("/api/admin/encryption/status", { + credentials: "include", + }); + }, + staleTime: 15_000, + }); + + const rotate = useMutation({ + mutationFn: async () => { + const res = await apiFetchRaw("/api/admin/encryption/rotate", { + method: "POST", + credentials: "include", + }); + if (!res.ok) { + if (res.status === 401) { + throw new Error(t("admin.section.encryption.stepUpNeeded")); + } + throw new Error(await getApiErrorMessage(res)); + } + return res.json(); + }, + onSuccess: () => { + toast.success(t("admin.section.encryption.rotateEnqueued")); + queryClient.invalidateQueries({ + queryKey: queryKeys.adminEncryptionStatus(), + }); + }, + onError: (err: Error) => { + toast.error(err.message || t("admin.section.encryption.rotateFailed")); + }, + }); + + if (statusQuery.isLoading) { + return ( +

+ + {t("admin.section.encryption.loading")} +
+ ); + } + + if (statusQuery.isError || !statusQuery.data) { + return ( +

+ {t("admin.section.encryption.loadError")} +

+ ); + } + + const s = statusQuery.data; + const running = s.rotation.state === "running" || rotate.isPending; + // Only the rows that share a coverage view need the per-column table; sort + // stale-first so an operator sees what still needs rotating. + const columns = [...s.columns].sort((a, b) => b.legacy - a.legacy); + + return ( +
+ {/* ── Coverage summary ───────────────────────────────────────── */} + + +
+ + + + +
+ {s.rotationComplete ? ( + + {t("admin.section.encryption.safeToDropLegacy")} + + ) : ( + + {t("admin.section.encryption.rotationIncomplete", { + count: s.staleRows, + })} + + )} +
+ + {/* ── Rotation status + trigger ──────────────────────────────── */} + + +
+

+ {t("admin.section.encryption.rotationState")}:{" "} + + {t(`admin.section.encryption.state.${s.rotation.state}`)} + +

+ {s.rotation.lastResult && ( +

+ {t("admin.section.encryption.lastRun", { + scanned: s.rotation.lastResult.scanned, + rotated: s.rotation.lastResult.rotated, + errors: s.rotation.lastResult.errors, + })} +

+ )} +
+ + + + + + + + + {t("admin.section.encryption.rotateConfirmTitle")} + + + {t("admin.section.encryption.rotateConfirmDescription")} + + + + + {t("admin.section.encryption.cancel")} + + rotate.mutate()}> + {t("admin.section.encryption.rotateConfirm")} + + + + + +

+ {t("admin.section.encryption.cliNote")} +

+
+ + {/* ── Per-column coverage table ──────────────────────────────── */} + + +
+ + + + + + + + + + + {columns.map((c) => { + const active = c.byKeyId[s.activeKeyId] ?? 0; + const stale = c.total - active; + return ( + + + + + + + ); + })} + +
+ {t("admin.section.encryption.colColumn")} + + {t("admin.section.encryption.colTotal")} + + {t("admin.section.encryption.colActive")} + + {t("admin.section.encryption.colStale")} +
+ {c.model}.{c.field} + {c.total.toLocaleString()}{active.toLocaleString()} + {stale > 0 ? ( + + {stale.toLocaleString()} + + ) : ( + 0 + )} +
+
+
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/src/components/admin/section-slugs.ts b/src/components/admin/section-slugs.ts index 45689aab4..901be36c9 100644 --- a/src/components/admin/section-slugs.ts +++ b/src/components/admin/section-slugs.ts @@ -37,6 +37,10 @@ export const ADMIN_SECTION_SLUGS = [ "login-overview", "app-logs", "backups", + // v1.23 — encryption coverage + key-rotation status / safe trigger. Sits + // next to Backups (both are data-at-rest concerns) and just before the + // danger zone. + "encryption", "danger-zone", // v1.4.36 W4e — About / version / update check folded into Admin // Console. Lives at /admin/about; the legacy /settings/about route diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index b2bba0cb4..3d67e2639 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -182,6 +182,16 @@ export function getActiveKeyId(): string { return getActiveKey().id; } +/** + * The configured key ids (NOT the key material). Used by the admin + * encryption-status view to report how many keys the operator has loaded and + * by the rotation guard. Never expose the bytes — only the ids. + */ +export function getConfiguredKeyIds(): string[] { + const { keys } = loadKeys(); + return [...keys.keys()]; +} + function encryptWithKey(plaintext: string, key: Buffer): string { const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); diff --git a/src/lib/crypto/__tests__/encryption-corpus.test.ts b/src/lib/crypto/__tests__/encryption-corpus.test.ts new file mode 100644 index 000000000..51216365c --- /dev/null +++ b/src/lib/crypto/__tests__/encryption-corpus.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Buffer } from "node:buffer"; +import { + encrypt, + extractKeyId, + getActiveKeyId, + getConfiguredKeyIds, + _resetCryptoCacheForTests, +} from "@/lib/crypto"; +import { + scanCorpus, + rotateCorpus, + LEGACY_BUCKET, + type CorpusClient, +} from "@/lib/crypto/encryption-corpus"; +import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns"; + +const KEY_V1 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const KEY_V2 = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + +function delegateKey(model: string): string { + return model.charAt(0).toLowerCase() + model.slice(1); +} + +interface Row { + id: string; + [field: string]: unknown; +} + +/** + * In-memory fake Prisma client covering every model in the registry, backed by + * a per-model row store. Only the models we seed hold rows; the rest are empty. + */ +function makeClient(seed: Record): { + client: CorpusClient; + store: Record; +} { + const store: Record = {}; + const client: CorpusClient = {}; + for (const col of ENCRYPTED_COLUMNS) { + const key = delegateKey(col.model); + if (!store[col.model]) store[col.model] = seed[col.model] ?? []; + if (client[key]) continue; + const model = col.model; + client[key] = { + findMany: async ({ select }) => { + const fields = Object.keys(select); + return store[model].map((row) => { + const out: Record = {}; + for (const f of fields) out[f] = row[f]; + return out; + }); + }, + update: async ({ where, data }) => { + const row = store[model].find((r) => r.id === where.id); + if (row) Object.assign(row, data); + return row; + }, + }; + } + return { client, store }; +} + +function activeUnder(keyId: string, plaintext: string): string { + // Encrypt while a given key id is active, then restore v2 as active. + process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId; + _resetCryptoCacheForTests(); + const ct = encrypt(plaintext); + process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2"; + _resetCryptoCacheForTests(); + return ct; +} + +describe("encryption-corpus scan + rotate", () => { + beforeEach(() => { + process.env.ENCRYPTION_KEYS = JSON.stringify({ v1: KEY_V1, v2: KEY_V2 }); + process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2"; + delete process.env.ENCRYPTION_KEY; + _resetCryptoCacheForTests(); + }); + + it("buckets rows by key id (string + bytes columns)", async () => { + const stringCt = activeUnder("v1", "token-under-v1"); + const stringActive = encrypt("token-under-v2"); + const bytesCt = Buffer.from(activeUnder("v1", "note-under-v1"), "utf8"); + + const { client } = makeClient({ + User: [ + { id: "u1", codexAccessTokenEncrypted: stringCt }, + { id: "u2", codexAccessTokenEncrypted: stringActive }, + ], + MoodEntry: [{ id: "m1", noteEncrypted: bytesCt }], + }); + + const scan = await scanCorpus(client); + expect(scan.activeKeyId).toBe("v2"); + + const userCol = scan.columns.find( + (c) => c.model === "User" && c.field === "codexAccessTokenEncrypted", + )!; + expect(userCol.total).toBe(2); + expect(userCol.byKeyId.v1).toBe(1); + expect(userCol.byKeyId.v2).toBe(1); + + const moodCol = scan.columns.find( + (c) => c.model === "MoodEntry" && c.field === "noteEncrypted", + )!; + expect(moodCol.total).toBe(1); + expect(moodCol.byKeyId.v1).toBe(1); + + // 3 rows total, 1 already on the active key. + expect(scan.totalRows).toBe(3); + expect(scan.activeRows).toBe(1); + expect(scan.staleRows).toBe(2); + expect(scan.rotationComplete).toBe(false); + }); + + it("rotates stale rows to the active key and is idempotent", async () => { + const { client, store } = makeClient({ + User: [{ id: "u1", codexAccessTokenEncrypted: activeUnder("v1", "x") }], + MoodEntry: [ + { + id: "m1", + noteEncrypted: Buffer.from(activeUnder("v1", "y"), "utf8"), + }, + ], + }); + + const first = await rotateCorpus(client); + expect(first.totalRotated).toBe(2); + expect(first.totalErrors).toBe(0); + + // ACTIVE-KEY-ONLY: every row now carries the active key id. + expect( + extractKeyId(store.User[0].codexAccessTokenEncrypted as string), + ).toBe(getActiveKeyId()); + expect( + extractKeyId( + Buffer.from(store.MoodEntry[0].noteEncrypted as Uint8Array).toString( + "utf8", + ), + ), + ).toBe(getActiveKeyId()); + + // IDEMPOTENT: a second pass rotates nothing. + const second = await rotateCorpus(client); + expect(second.totalRotated).toBe(0); + + const scan = await scanCorpus(client); + expect(scan.rotationComplete).toBe(true); + expect(scan.staleRows).toBe(0); + }); + + it("never adds or drops a key during rotation", async () => { + const before = getConfiguredKeyIds().sort(); + const { client } = makeClient({ + User: [{ id: "u1", codexAccessTokenEncrypted: activeUnder("v1", "x") }], + }); + await rotateCorpus(client); + const after = getConfiguredKeyIds().sort(); + expect(after).toEqual(before); + expect(after).toEqual(["v1", "v2"]); + }); + + it("fails closed on a row under a no-longer-configured key (no data loss)", async () => { + // A ciphertext whose key id ('v9') is not in ENCRYPTION_KEYS. Decrypt + // throws -> counted as an error, the row is left untouched. + const orphan = "v9." + Buffer.from("garbage").toString("base64"); + const { client, store } = makeClient({ + User: [{ id: "u1", codexAccessTokenEncrypted: orphan }], + }); + + const result = await rotateCorpus(client); + expect(result.totalErrors).toBe(1); + expect(result.totalRotated).toBe(0); + // Row preserved exactly, not dropped or blanked. + expect(store.User[0].codexAccessTokenEncrypted).toBe(orphan); + }); + + it("treats legacy (unversioned) ciphertext as stale and buckets it", async () => { + // Legacy bare-base64 row written by the old single-key path. + process.env.ENCRYPTION_KEYS = ""; + process.env.ENCRYPTION_ACTIVE_KEY_ID = ""; + process.env.ENCRYPTION_KEY = KEY_V1; + _resetCryptoCacheForTests(); + const legacy = encrypt("legacy-secret").replace(/^v1\./, ""); // strip prefix + + process.env.ENCRYPTION_KEYS = JSON.stringify({ v1: KEY_V1, v2: KEY_V2 }); + process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2"; + delete process.env.ENCRYPTION_KEY; + _resetCryptoCacheForTests(); + + const { client } = makeClient({ + User: [{ id: "u1", codexAccessTokenEncrypted: legacy }], + }); + const scan = await scanCorpus(client); + const col = scan.columns.find( + (c) => c.model === "User" && c.field === "codexAccessTokenEncrypted", + )!; + expect(col.legacy).toBe(1); + expect(col.byKeyId[LEGACY_BUCKET]).toBe(1); + }); +}); diff --git a/src/lib/crypto/encryption-corpus.ts b/src/lib/crypto/encryption-corpus.ts new file mode 100644 index 000000000..f8574a30f --- /dev/null +++ b/src/lib/crypto/encryption-corpus.ts @@ -0,0 +1,260 @@ +/** + * v1.23 — read-only scan + in-place re-encrypt over the whole encrypted-column + * corpus, driven by the canonical registry (`encrypted-columns.ts`). + * + * Two consumers: + * - the admin encryption-status view (`GET /api/admin/encryption/status`) + * calls `scanCorpus()` to bucket every encrypted column's rows by key id; + * - the admin-triggered rotation pg-boss job (`encryption-key-rotate`) calls + * `rotateCorpus()` to re-encrypt every row that is not already on the + * active key. + * + * The standalone CLI (`scripts/rotate-encryption-key.ts`) remains the canonical + * rotation path and stays independent (its own Prisma client). This module is + * the in-app convenience that reuses the SAME registry, so the guard test keeps + * both in lock-step. + * + * GUARANTEES (the security review must confirm these on the rotation path): + * - ACTIVE-KEY-ONLY. Re-encryption is `encrypt(decrypt(value))`; `encrypt()` + * always writes the configured active key id. There is no code path here + * that selects any other write key. + * - NEVER ADDS / DROPS A KEY. This module never reads or mutates + * `ENCRYPTION_KEYS` / `ENCRYPTION_ACTIVE_KEY_ID`. The operator's env key map + * is the only place keys live; a key drop stays a deliberate env + redeploy + * act, never a button. + * - IDEMPOTENT. `shouldRotate()` skips rows already on the active key, so a + * second pass (or two racing workers) re-encrypts zero rows. + * - FAIL-CLOSED. A row written under a key id that is no longer configured + * throws on decrypt (counted as an error, the row is left untouched) rather + * than being silently dropped or overwritten — exactly the property that + * protects against dropping a legacy key too early. + */ +import { Buffer } from "node:buffer"; +import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto"; +import { + ENCRYPTED_COLUMNS, + encryptedColumnKey, + type EncryptedColumn, +} from "@/lib/crypto/encrypted-columns"; + +/** Sentinel bucket for legacy (unversioned) ciphertext under `byKeyId`. */ +export const LEGACY_BUCKET = "legacy"; + +/** Minimal Prisma delegate shape this module needs. */ +interface ColumnDelegate { + findMany: (args: { + select: Record; + }) => Promise>>; + update: (args: { + where: { id: string }; + data: Record; + }) => Promise; +} + +/** The subset of the Prisma client we touch: one delegate per model. */ +export type CorpusClient = Record; + +/** PascalCase model name -> camelCase Prisma delegate key. */ +function delegateKey(model: string): string { + return model.charAt(0).toLowerCase() + model.slice(1); +} + +function getDelegate(client: CorpusClient, model: string): ColumnDelegate { + const delegate = client[delegateKey(model)]; + if (!delegate) { + throw new Error(`No Prisma delegate for model '${model}'`); + } + return delegate; +} + +/** Read a registry column's value as a ciphertext string (Bytes -> utf8). */ +function toCiphertext( + value: unknown, + kind: EncryptedColumn["kind"], +): string | null { + if (value == null) return null; + if (kind === "bytes") { + const buf = value as Uint8Array; + if (buf.byteLength === 0) return null; + return Buffer.from(buf).toString("utf8"); + } + const s = value as string; + return s.length === 0 ? null : s; +} + +/** Encode a re-encrypted ciphertext string back into the column's storage shape. */ +function fromCiphertext( + value: string, + kind: EncryptedColumn["kind"], +): string | Uint8Array { + if (kind !== "bytes") return value; + const encoded = Buffer.from(value, "utf8"); + const next = new Uint8Array(new ArrayBuffer(encoded.byteLength)); + next.set(encoded); + return next; +} + +function shouldRotate(ciphertext: string): boolean { + return extractKeyId(ciphertext) !== getActiveKeyId(); +} + +export interface ColumnScan { + model: string; + field: string; + kind: EncryptedColumn["kind"]; + /** Non-null ciphertext rows. */ + total: number; + /** Rows per key id; legacy/unversioned rows land under `LEGACY_BUCKET`. */ + byKeyId: Record; + /** Rows under the legacy/unversioned format (= `byKeyId[LEGACY_BUCKET]`). */ + legacy: number; +} + +/** Scan one encrypted column: bucket every non-null ciphertext by key id. */ +export async function scanColumn( + client: CorpusClient, + col: EncryptedColumn, +): Promise { + const delegate = getDelegate(client, col.model); + const rows = await delegate.findMany({ + select: { id: true, [col.field]: true }, + }); + const byKeyId: Record = {}; + let total = 0; + for (const row of rows) { + const ciphertext = toCiphertext(row[col.field], col.kind); + if (ciphertext == null) continue; + total += 1; + const id = extractKeyId(ciphertext) ?? LEGACY_BUCKET; + byKeyId[id] = (byKeyId[id] ?? 0) + 1; + } + return { + model: col.model, + field: col.field, + kind: col.kind, + total, + byKeyId, + legacy: byKeyId[LEGACY_BUCKET] ?? 0, + }; +} + +export interface CorpusScan { + activeKeyId: string; + columns: ColumnScan[]; + /** Total non-null ciphertext rows across the corpus. */ + totalRows: number; + /** Rows already on the active key. */ + activeRows: number; + /** Rows NOT on the active key (legacy + any non-active versioned). */ + staleRows: number; + /** + * True iff every column has zero rows that are not on the active key — the + * single signal an operator needs before dropping a legacy key. + */ + rotationComplete: boolean; +} + +/** Scan the whole corpus. Read-only; never writes. */ +export async function scanCorpus(client: CorpusClient): Promise { + const activeKeyId = getActiveKeyId(); + const columns: ColumnScan[] = []; + for (const col of ENCRYPTED_COLUMNS) { + columns.push(await scanColumn(client, col)); + } + let totalRows = 0; + let activeRows = 0; + for (const c of columns) { + totalRows += c.total; + activeRows += c.byKeyId[activeKeyId] ?? 0; + } + const staleRows = totalRows - activeRows; + return { + activeKeyId, + columns, + totalRows, + activeRows, + staleRows, + rotationComplete: staleRows === 0, + }; +} + +export interface RotationResult { + model: string; + field: string; + scanned: number; + rotated: number; + errors: number; +} + +/** Re-encrypt one column's stale rows to the active key. */ +export async function rotateColumn( + client: CorpusClient, + col: EncryptedColumn, +): Promise { + const delegate = getDelegate(client, col.model); + const rows = await delegate.findMany({ + select: { id: true, [col.field]: true }, + }); + const result: RotationResult = { + model: col.model, + field: col.field, + scanned: rows.length, + rotated: 0, + errors: 0, + }; + for (const row of rows) { + const ciphertext = toCiphertext(row[col.field], col.kind); + if (ciphertext == null || !shouldRotate(ciphertext)) continue; + const id = row.id as string; + try { + // ACTIVE-KEY-ONLY: encrypt() always writes the active key id. + const reencrypted = encrypt(decrypt(ciphertext)); + await delegate.update({ + where: { id }, + data: { [col.field]: fromCiphertext(reencrypted, col.kind) }, + }); + result.rotated += 1; + } catch { + // FAIL-CLOSED: a row under a no-longer-configured key throws on decrypt; + // count it and leave the row untouched rather than dropping data. + result.errors += 1; + } + } + return result; +} + +export interface CorpusRotation { + activeKeyId: string; + results: RotationResult[]; + totalScanned: number; + totalRotated: number; + totalErrors: number; +} + +/** Re-encrypt the whole corpus to the active key. Idempotent + active-key-only. */ +export async function rotateCorpus( + client: CorpusClient, +): Promise { + const activeKeyId = getActiveKeyId(); + const results: RotationResult[] = []; + for (const col of ENCRYPTED_COLUMNS) { + results.push(await rotateColumn(client, col)); + } + let totalScanned = 0; + let totalRotated = 0; + let totalErrors = 0; + for (const r of results) { + totalScanned += r.scanned; + totalRotated += r.rotated; + totalErrors += r.errors; + } + return { + activeKeyId, + results, + totalScanned, + totalRotated, + totalErrors, + }; +} + +export { encryptedColumnKey }; diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index d7472917e..d1caeac07 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -27,6 +27,8 @@ export const adminKeys = { adminModuleAvailability: () => ["admin", "settings", "module-availability"] as const, adminBackups: () => ["admin", "backups"] as const, + /** v1.23 — encryption coverage + rotation progress (admin Encryption section). */ + adminEncryptionStatus: () => ["admin", "encryption", "status"] as const, adminCoachFeedback: () => ["admin", "coach-feedback"] as const, adminFeedback: (status: string) => ["admin", "feedback", status] as const, adminFeedbackRoot: () => ["admin", "feedback"] as const, From bb24ad5c149028fe6613320b128447558413f134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:27:40 +0200 Subject: [PATCH 09/34] feat(admin): allow a guarded key-rotation run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional admin-triggered key rotation on pg-boss that re-encrypts the whole encrypted-column corpus to the active key, reusing the same column registry as the command-line tool (which stays the canonical path). POST /api/admin/encryption/rotate enqueues a singleton run and is gated by cookie-only admin plus a fresh second factor. The run is idempotent (rows already on the active key are skipped), active-key-only, and never adds or drops a key — the env key map stays operator-owned, so a key drop remains a deliberate redeploy. It fails closed per row and reports totals into the audit trail the status view reads. --- src/app/api/admin/encryption/rotate/route.ts | 73 +++++++++++++++ src/lib/jobs/encryption-key-rotate.ts | 92 +++++++++++++++++++ src/lib/jobs/reminder/register-maintenance.ts | 18 ++++ 3 files changed, 183 insertions(+) create mode 100644 src/app/api/admin/encryption/rotate/route.ts create mode 100644 src/lib/jobs/encryption-key-rotate.ts diff --git a/src/app/api/admin/encryption/rotate/route.ts b/src/app/api/admin/encryption/rotate/route.ts new file mode 100644 index 000000000..0b6c23bbc --- /dev/null +++ b/src/app/api/admin/encryption/rotate/route.ts @@ -0,0 +1,73 @@ +/** + * POST /api/admin/encryption/rotate + * + * v1.23 — admin-triggered encryption-key rotation. Enqueues the + * `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted- + * column corpus to the configured ACTIVE key id. The CLI + * (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a + * convenience that reuses the same column registry and is safe by construction: + * + * - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active + * key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env + + * redeploy act. + * - IDEMPOTENT: rows already on the active key are skipped; the singleton key + * coalesces duplicate triggers into one queued run. + * + * Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS + * `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a + * high-blast-radius action. An admin without a second factor cannot use the + * button and falls back to the CLI (this is intentional: step-up cannot be + * softened, and the CLI is the documented canonical path). + */ +import { + apiHandler, + requireAdmin, + requireFreshMfa, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { + ENCRYPTION_KEY_ROTATE_QUEUE, + ENCRYPTION_KEY_ROTATE_SINGLETON, +} from "@/lib/jobs/encryption-key-rotate"; +import { getActiveKeyId } from "@/lib/crypto"; +import { NextRequest } from "next/server"; + +export const POST = apiHandler(async (request: NextRequest) => { + // Cookie-only admin, then a fresh second factor. Both resolution paths are + // cookie-only, so a Bearer token cannot reach either. + await requireAdmin(); + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + annotate({ action: { name: "admin.encryption.rotate.requested" } }); + + const boss = getGlobalBoss(); + if (!boss) { + return apiError("Background worker is not available", 503); + } + + const jobId = await boss.send( + ENCRYPTION_KEY_ROTATE_QUEUE, + { requestedByUserId: user.id, enqueuedAt: new Date().toISOString() }, + { + // Coalesce duplicate triggers: at most one queued run at a time. + singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON, + retryLimit: 2, + retryDelay: 60, + }, + ); + + await auditLog("admin.encryption.rotate.requested", { + userId: user.id, + ipAddress: getClientIp(request), + details: { activeKeyId: getActiveKeyId(), enqueued: Boolean(jobId) }, + }); + + annotate({ meta: { rotate_enqueued: Boolean(jobId) } }); + + // A null jobId means the singleton coalesced this trigger onto an already- + // queued run — still "accepted" from the operator's point of view. + return apiSuccess({ accepted: true, alreadyQueued: !jobId }); +}); diff --git a/src/lib/jobs/encryption-key-rotate.ts b/src/lib/jobs/encryption-key-rotate.ts new file mode 100644 index 000000000..be6652341 --- /dev/null +++ b/src/lib/jobs/encryption-key-rotate.ts @@ -0,0 +1,92 @@ +/** + * v1.23 — admin-triggered encryption-key rotation, on pg-boss. + * + * Re-encrypts every registered encrypted column to the configured ACTIVE key + * id, reusing the canonical column registry via `rotateCorpus()`. This is the + * in-app convenience the admin panel triggers; the CLI + * (`scripts/rotate-encryption-key.ts`) remains the documented canonical path. + * pg-boss is the right home because the standalone production image strips + * `tsx`, so a button that ran the CLI inside the container would fail. + * + * SAFETY (security-review surface): + * - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: `rotateCorpus()` only ever + * writes the active key and never touches the env key map. Dropping a key + * stays an operator env + redeploy act. + * - IDEMPOTENT: rows already on the active key are skipped, so re-running (or + * the singleton coalescing a duplicate trigger) re-encrypts nothing. + * - FAIL-CLOSED per row: a row under a no-longer-configured key is counted as + * an error and left untouched, never dropped. + * + * On-demand only — no cron. The admin POST enqueues with a fixed singletonKey + * so two concurrent triggers collapse into one run. Completion is recorded both + * as a background wide-event and an `admin.encryption.rotate.completed` audit + * entry that the status view reads to show the last run. + */ +import { type Job } from "pg-boss"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { auditLog } from "@/lib/auth/audit"; +import { + rotateCorpus, + type CorpusClient, +} from "@/lib/crypto/encryption-corpus"; +import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; + +export const ENCRYPTION_KEY_ROTATE_QUEUE = "encryption-key-rotate"; +export const ENCRYPTION_KEY_ROTATE_CONCURRENCY = 1; +/** Fixed key so duplicate admin triggers coalesce into one queued run. */ +export const ENCRYPTION_KEY_ROTATE_SINGLETON = "encryption-key-rotate"; + +export interface EncryptionKeyRotatePayload { + /** The admin who triggered the run (for the audit trail). */ + requestedByUserId?: string; + enqueuedAt?: string; +} + +export async function runEncryptionKeyRotation(): Promise<{ + activeKeyId: string; + totalScanned: number; + totalRotated: number; + totalErrors: number; +}> { + const prisma = getWorkerPrisma(); + const out = await rotateCorpus(prisma as unknown as CorpusClient); + return { + activeKeyId: out.activeKeyId, + totalScanned: out.totalScanned, + totalRotated: out.totalRotated, + totalErrors: out.totalErrors, + }; +} + +export async function handleEncryptionKeyRotate( + jobs: Job[], +) { + await withBackgroundEvent("job.encryption_key_rotate", async (evt) => { + const requestedBy = jobs[0]?.data?.requestedByUserId ?? null; + try { + const result = await runEncryptionKeyRotation(); + evt.addMeta("rotate_active_key_id", result.activeKeyId); + evt.addMeta("rotate_scanned", result.totalScanned); + evt.addMeta("rotate_rotated", result.totalRotated); + evt.addMeta("rotate_errors", result.totalErrors); + await auditLog("admin.encryption.rotate.completed", { + userId: requestedBy, + details: { + activeKeyId: result.activeKeyId, + scanned: result.totalScanned, + rotated: result.totalRotated, + errors: result.totalErrors, + }, + }); + } catch (err) { + evt.addWarning(`encryption-key-rotate failed: ${err}`); + await auditLog("admin.encryption.rotate.failed", { + userId: requestedBy, + details: { message: err instanceof Error ? err.message : String(err) }, + }); + // Re-throw so pg-boss records the failure (the run is idempotent, so a + // retry is safe). + throw err; + } + }); +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index b5ca7874e..3e3b7c765 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -62,6 +62,12 @@ import { enqueueBootTimeNoteEncryptionBackfill, type NoteEncryptionBackfillPayload, } from "@/lib/jobs/note-encryption-backfill"; +import { + ENCRYPTION_KEY_ROTATE_QUEUE, + ENCRYPTION_KEY_ROTATE_CONCURRENCY, + handleEncryptionKeyRotate, + type EncryptionKeyRotatePayload, +} from "@/lib/jobs/encryption-key-rotate"; import { recordError } from "@/lib/jobs/worker-status"; import { workerLog } from "./shared"; import { createAndSchedule, type ScheduleEntry } from "./registrar-shared"; @@ -228,6 +234,11 @@ const allQueues = [ // 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, + // v1.23 — admin-triggered encryption-key rotation. On-demand only (no + // cron): the admin panel enqueues a singleton run that re-encrypts the + // corpus to the active key. Without this entry pg-boss never provisions the + // queue and the admin trigger silently no-ops. + ENCRYPTION_KEY_ROTATE_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -490,6 +501,13 @@ export async function registerMaintenanceQueues( }, ); + // v1.23 — admin-triggered encryption-key rotation (on-demand, singleton). + await boss.work( + ENCRYPTION_KEY_ROTATE_QUEUE, + { localConcurrency: ENCRYPTION_KEY_ROTATE_CONCURRENCY }, + handleEncryptionKeyRotate, + ); + return allQueues; } From 01689f77bca2ff395105da1c9d2bb5a284f4fcb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:34:11 +0200 Subject: [PATCH 10/34] feat(security): require fresh verification before destructive account actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account deletion and the keep-account data reset now demand a recent second-factor re-verification for accounts that have a second factor active; single-factor accounts are unaffected and keep the typed- confirmation-only contract. The gate runs through requireFreshMfaIfEnrolled, which is cookie-only by construction — a Bearer transport for an MFA-enrolled account cannot satisfy step-up and surfaces the auth.stepup.required envelope so the UI can launch a re-verification flow. --- src/app/api/settings/account/route.ts | 13 +- src/app/api/settings/data/route.ts | 12 +- .../require-fresh-mfa-if-enrolled.test.ts | 117 ++++++++++++++++++ src/lib/api-handler.ts | 28 +++++ 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index d5f0d3da3..28d6e584d 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -3,7 +3,11 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { destroyAllSessions } from "@/lib/auth/session"; import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + requireFreshMfaIfEnrolled, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; export const dynamic = "force-dynamic"; @@ -11,9 +15,14 @@ export const dynamic = "force-dynamic"; /** * Permanently delete the user account and ALL associated data. * Cascading deletes in the schema handle all related records. + * + * v1.23 — for an account with a second factor active this requires a fresh + * step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition to the typed + * confirmation, so a hijacked live session cannot erase the record. Accounts + * without MFA are unaffected and keep the typed-confirmation-only contract. */ export const DELETE = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); let confirm = ""; try { diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index b537b765e..e938a843b 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -2,7 +2,11 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + requireFreshMfaIfEnrolled, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { invalidateUserData } from "@/lib/cache/invalidate"; @@ -10,9 +14,13 @@ export const dynamic = "force-dynamic"; /** * Delete all user-owned health/integration data while keeping the account. + * + * v1.23 — gated by a fresh step-up for MFA-enrolled accounts (see the account + * deletion route); single-factor accounts keep the typed-confirmation-only + * contract. */ export const DELETE = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); let confirm = ""; try { diff --git a/src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts b/src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts new file mode 100644 index 000000000..a05a523a2 --- /dev/null +++ b/src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mirror require-fresh-mfa.test.ts mocking so the api-handler module loads +// without a live DB / request scope. +vi.mock("@/lib/db", () => ({ + prisma: { + session: { findUnique: vi.fn() }, + apiToken: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +const headersGet = vi.fn<(name: string) => string | null>(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: headersGet })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { + requireFreshMfaIfEnrolled, + StepUpRequiredError, + HttpError, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "../api-handler"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { headers } from "next/headers"; +import { auditLog } from "@/lib/auth/audit"; + +const MFA_USER = { + id: "user-1", + role: "USER" as const, + username: "u", + totpConfirmedAt: new Date("2020-01-01"), +}; +const PLAIN_USER = { ...MFA_USER, totpConfirmedAt: null }; + +beforeEach(() => { + vi.resetAllMocks(); + headersGet.mockReturnValue(null); + // resetAllMocks wipes the inline mock factory implementation too; restore + // the headers() wrapper so requireAuth's Bearer branch can read the header. + vi.mocked(headers).mockImplementation( + async () => ({ get: headersGet }) as never, + ); + // resetAllMocks wipes the factory mockResolvedValue; auditLog is awaited + // via `.catch(...)` in the Bearer path, so it must return a thenable. + vi.mocked(auditLog).mockResolvedValue(undefined); +}); + +function mockCookieSession(user: unknown) { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) }, + user, + } as never); +} + +describe("requireFreshMfaIfEnrolled", () => { + it("passes a non-MFA user straight through without a freshness read", async () => { + mockCookieSession(PLAIN_USER); + const ctx = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); + expect(ctx.user.id).toBe("user-1"); + expect(prisma.session.findUnique).not.toHaveBeenCalled(); + }); + + it("passes an MFA user with a fresh verification", async () => { + mockCookieSession(MFA_USER); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(Date.now() - 60_000), + } as never); + const ctx = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); + expect(ctx.user.id).toBe("user-1"); + }); + + it("blocks an MFA user without a fresh verification", async () => { + mockCookieSession(MFA_USER); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: null, + } as never); + await expect( + requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS), + ).rejects.toBeInstanceOf(StepUpRequiredError); + }); + + it("a Bearer token can never satisfy step-up for an MFA-enrolled account", async () => { + // requireAuth resolves the Bearer caller (no cookie), but the step-up read + // is cookie-only — getSession returns null inside requireFreshMfa. + vi.mocked(getSession).mockResolvedValue(null as never); + headersGet.mockImplementation((n) => + n.toLowerCase() === "authorization" ? "Bearer hlk_xyz" : null, + ); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-1", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); + + await expect( + requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS), + ).rejects.toBeInstanceOf(HttpError); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index d0761558f..e95b4ca80 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -557,6 +557,34 @@ export async function requireFreshMfa( return { ...sessionData, mfaVerifiedAt: verifiedAt }; } +/** + * v1.23 — conditional step-up for destructive account actions. + * + * Resolves the caller with the standard `requireAuth()` (cookie OR Bearer). + * For an account WITHOUT a confirmed second factor the caller passes straight + * through — a single-factor user is intentionally unaffected, so account + * deletion / data reset keeps its existing typed-confirmation-only contract. + * For an account WITH MFA active (`totpConfirmedAt` set) it additionally runs + * `requireFreshMfa`, which is cookie-only by construction: an MFA-enrolled + * account's Bearer transport carries no `mfaVerifiedAt` and therefore cannot + * satisfy step-up, surfacing `StepUpRequiredError` (401, + * `errorCode: "auth.stepup.required"`) so the UI launches a re-verification. + * + * Gating only the MFA-enrolled cohort keeps the boundary structural: a + * hijacked live cookie session for an MFA user cannot nuke the record without + * a fresh factor, while users who never opted into MFA are not forced through + * a flow they have no way to complete. + */ +export async function requireFreshMfaIfEnrolled( + maxAgeSeconds: number, +): Promise { + const auth = await requireAuth(); + if (auth.user.totpConfirmedAt) { + await requireFreshMfa(maxAgeSeconds); + } + return auth; +} + /** * Report unhandled errors to GlitchTip (fire-and-forget). * Uses dynamic import to avoid circular dependencies and startup cost. From b882698a44cf5d80bdb63fe2539f3fee1f98bfb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:34:20 +0200 Subject: [PATCH 11/34] feat(auth): warn on breached passwords at password set Check a newly chosen password against HaveIBeenPwned through the k-anonymity range API: only the first five hex characters of the SHA-1 digest leave the host, the suffix is matched locally, and Add-Padding hides the bucket size. A confirmed match is rejected so a fresh credential is never a known-breached one; the check fails open, so an unreachable HIBP never blocks the operation. Covers registration, password change, and admin reset. Existing passwords are never retroactively blocked at login. Also lands the six-locale strings for the password-breach notice and the account-security surfaces that follow. --- messages/de.json | 42 ++++++++- messages/en.json | 42 ++++++++- messages/es.json | 42 ++++++++- messages/fr.json | 42 ++++++++- messages/it.json | 42 ++++++++- messages/pl.json | 42 ++++++++- .../admin/users/[id]/reset-password/route.ts | 12 +++ src/app/api/auth/password/route.ts | 14 +++ src/lib/auth/__tests__/hibp.test.ts | 90 +++++++++++++++++++ src/lib/auth/hibp.ts | 77 ++++++++++++++++ 10 files changed, 427 insertions(+), 18 deletions(-) create mode 100644 src/lib/auth/__tests__/hibp.test.ts create mode 100644 src/lib/auth/hibp.ts diff --git a/messages/de.json b/messages/de.json index 523158b31..8f7d31981 100644 --- a/messages/de.json +++ b/messages/de.json @@ -263,7 +263,8 @@ "passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.", "passwordMinLength": "Min. 12 Zeichen", "passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.", - "privacyPolicy": "Datenschutzerklärung" + "privacyPolicy": "Datenschutzerklärung", + "passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes." }, "dashboard": { "title": "Dashboard", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", - "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben." + "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.", + "security": { + "sessionsTitle": "Aktive Sitzungen", + "sessionsDescription": "Geräte und Browser, die aktuell bei deinem Konto angemeldet sind.", + "sessionsLoadError": "Aktive Sitzungen konnten nicht geladen werden.", + "sessionsEmpty": "Keine aktiven Sitzungen.", + "currentSession": "Dieses Gerät", + "lastActive": "Zuletzt aktiv {time}", + "unknownLocation": "Unbekannter Ort", + "revokeSession": "Abmelden", + "revokeError": "Anfrage konnte nicht abgeschlossen werden. Bitte erneut versuchen.", + "signOutEverywhere": "Überall sonst abmelden", + "signOutEverywhereDone": "{count} andere Sitzung(en) abgemeldet.", + "activityTitle": "Sicherheitsaktivität", + "activityDescription": "Letzte Anmeldungen, Sicherheitsänderungen und Datenexporte deines Kontos.", + "activityEmpty": "Noch keine Sicherheitsaktivität.", + "activityLoadError": "Sicherheitsaktivität konnte nicht geladen werden.", + "action": { + "newDevice": "Anmeldung von neuem Gerät", + "loginFailed": "Fehlgeschlagene Anmeldung", + "login": "Anmeldung", + "passwordChange": "Passwort geändert", + "mfa": "Zwei-Faktor-Änderung", + "sessionRevoke": "Sitzung abgemeldet", + "token": "API-Token-Änderung", + "accountDelete": "Konto gelöscht", + "dataClear": "Daten zurückgesetzt", + "export": "Datenexport", + "other": "Sicherheitsereignis" + } + } }, "admin": { "title": "Administration", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Mess-Erinnerung", "eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.", "eventMedicationIntakeSync": "Einnahme-Synchronisierung", - "eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird." + "eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.", + "security": { + "newDeviceTitle": "Neue Anmeldung bei deinem Konto", + "newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.", + "unknownLocation": "unbekannter Ort" + } }, "i18n": { "maintainershipBanner": { diff --git a/messages/en.json b/messages/en.json index 16ec52b02..5da217315 100644 --- a/messages/en.json +++ b/messages/en.json @@ -263,7 +263,8 @@ "passkeySetupLater": "You can set up passkeys at any time in Settings.", "passwordMinLength": "Min. 12 characters", "passwordTooShort": "Password must be at least {minLength} characters long.", - "privacyPolicy": "Privacy policy" + "privacyPolicy": "Privacy policy", + "passwordBreached": "This password has appeared in known data breaches. Please choose a different one." }, "dashboard": { "title": "Dashboard", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", - "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above." + "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.", + "security": { + "sessionsTitle": "Active sessions", + "sessionsDescription": "Devices and browsers currently signed in to your account.", + "sessionsLoadError": "Active sessions could not be loaded.", + "sessionsEmpty": "No active sessions.", + "currentSession": "This device", + "lastActive": "Last active {time}", + "unknownLocation": "Unknown location", + "revokeSession": "Sign out", + "revokeError": "Could not complete the request. Please try again.", + "signOutEverywhere": "Sign out everywhere else", + "signOutEverywhereDone": "Signed out {count} other session(s).", + "activityTitle": "Security activity", + "activityDescription": "Recent sign-ins, security changes, and data exports on your account.", + "activityEmpty": "No security activity yet.", + "activityLoadError": "Security activity could not be loaded.", + "action": { + "newDevice": "New-device sign-in", + "loginFailed": "Failed sign-in", + "login": "Sign-in", + "passwordChange": "Password changed", + "mfa": "Two-factor change", + "sessionRevoke": "Session signed out", + "token": "API token change", + "accountDelete": "Account deleted", + "dataClear": "Data reset", + "export": "Data export", + "other": "Security event" + } + } }, "admin": { "title": "Administration", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Measurement reminder", "eventMeasurementReminderDesc": "Sent when a tracked measurement is due.", "eventMedicationIntakeSync": "Medication intake sync", - "eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped." + "eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.", + "security": { + "newDeviceTitle": "New sign-in to your account", + "newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.", + "unknownLocation": "unknown location" + } }, "i18n": { "maintainershipBanner": { diff --git a/messages/es.json b/messages/es.json index 8bea74277..4548ca6b3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -263,7 +263,8 @@ "passkeySetupLater": "Puedes configurar passkeys más tarde en los ajustes.", "passwordMinLength": "Mín. 12 caracteres", "passwordTooShort": "La contraseña debe tener al menos {minLength} caracteres.", - "privacyPolicy": "Política de privacidad" + "privacyPolicy": "Política de privacidad", + "passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra." }, "dashboard": { "title": "Panel", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", - "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba." + "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.", + "security": { + "sessionsTitle": "Sesiones activas", + "sessionsDescription": "Dispositivos y navegadores con sesión iniciada en tu cuenta.", + "sessionsLoadError": "No se pudieron cargar las sesiones activas.", + "sessionsEmpty": "No hay sesiones activas.", + "currentSession": "Este dispositivo", + "lastActive": "Última actividad {time}", + "unknownLocation": "Ubicación desconocida", + "revokeSession": "Cerrar sesión", + "revokeError": "No se pudo completar la solicitud. Inténtalo de nuevo.", + "signOutEverywhere": "Cerrar sesión en los demás dispositivos", + "signOutEverywhereDone": "Se cerraron {count} sesión(es).", + "activityTitle": "Actividad de seguridad", + "activityDescription": "Inicios de sesión, cambios de seguridad y exportaciones recientes de tu cuenta.", + "activityEmpty": "Aún no hay actividad de seguridad.", + "activityLoadError": "No se pudo cargar la actividad de seguridad.", + "action": { + "newDevice": "Inicio desde nuevo dispositivo", + "loginFailed": "Inicio de sesión fallido", + "login": "Inicio de sesión", + "passwordChange": "Contraseña cambiada", + "mfa": "Cambio de doble factor", + "sessionRevoke": "Sesión cerrada", + "token": "Cambio de token de API", + "accountDelete": "Cuenta eliminada", + "dataClear": "Datos restablecidos", + "export": "Exportación de datos", + "other": "Evento de seguridad" + } + } }, "admin": { "title": "Administration", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Recordatorio de medición", "eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.", "eventMedicationIntakeSync": "Sincronización de tomas", - "eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis." + "eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.", + "security": { + "newDeviceTitle": "Nuevo inicio de sesión en tu cuenta", + "newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.", + "unknownLocation": "ubicación desconocida" + } }, "i18n": { "maintainershipBanner": { diff --git a/messages/fr.json b/messages/fr.json index d943773ff..fe6010b7f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -263,7 +263,8 @@ "passkeySetupLater": "Tu peux configurer les passkeys plus tard dans les paramètres.", "passwordMinLength": "Min. 12 caractères", "passwordTooShort": "Le mot de passe doit comporter au moins {minLength} caractères.", - "privacyPolicy": "Politique de confidentialité" + "privacyPolicy": "Politique de confidentialité", + "passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre." }, "dashboard": { "title": "Tableau de bord", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", - "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus." + "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.", + "security": { + "sessionsTitle": "Sessions actives", + "sessionsDescription": "Appareils et navigateurs actuellement connectés à votre compte.", + "sessionsLoadError": "Impossible de charger les sessions actives.", + "sessionsEmpty": "Aucune session active.", + "currentSession": "Cet appareil", + "lastActive": "Dernière activité {time}", + "unknownLocation": "Lieu inconnu", + "revokeSession": "Déconnecter", + "revokeError": "Impossible de traiter la demande. Veuillez réessayer.", + "signOutEverywhere": "Se déconnecter partout ailleurs", + "signOutEverywhereDone": "{count} autre(s) session(s) déconnectée(s).", + "activityTitle": "Activité de sécurité", + "activityDescription": "Connexions récentes, modifications de sécurité et exports de données de votre compte.", + "activityEmpty": "Aucune activité de sécurité pour l'instant.", + "activityLoadError": "Impossible de charger l'activité de sécurité.", + "action": { + "newDevice": "Connexion depuis un nouvel appareil", + "loginFailed": "Échec de connexion", + "login": "Connexion", + "passwordChange": "Mot de passe modifié", + "mfa": "Modification de la double authentification", + "sessionRevoke": "Session déconnectée", + "token": "Modification de jeton API", + "accountDelete": "Compte supprimé", + "dataClear": "Données réinitialisées", + "export": "Export de données", + "other": "Événement de sécurité" + } + } }, "admin": { "title": "Administration", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Rappel de mesure", "eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.", "eventMedicationIntakeSync": "Synchronisation des prises", - "eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée." + "eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.", + "security": { + "newDeviceTitle": "Nouvelle connexion à votre compte", + "newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.", + "unknownLocation": "lieu inconnu" + } }, "i18n": { "maintainershipBanner": { diff --git a/messages/it.json b/messages/it.json index 38c204fbb..8b3867b5c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -263,7 +263,8 @@ "passkeySetupLater": "Puoi configurare le passkey più tardi dalle impostazioni.", "passwordMinLength": "Min. 12 caratteri", "passwordTooShort": "La password deve avere almeno {minLength} caratteri.", - "privacyPolicy": "Informativa sulla privacy" + "privacyPolicy": "Informativa sulla privacy", + "passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra." }, "dashboard": { "title": "Cruscotto", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", - "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra." + "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.", + "security": { + "sessionsTitle": "Sessioni attive", + "sessionsDescription": "Dispositivi e browser attualmente connessi al tuo account.", + "sessionsLoadError": "Impossibile caricare le sessioni attive.", + "sessionsEmpty": "Nessuna sessione attiva.", + "currentSession": "Questo dispositivo", + "lastActive": "Ultima attività {time}", + "unknownLocation": "Posizione sconosciuta", + "revokeSession": "Disconnetti", + "revokeError": "Impossibile completare la richiesta. Riprova.", + "signOutEverywhere": "Disconnetti ovunque", + "signOutEverywhereDone": "Disconnesse {count} altra/e sessione/i.", + "activityTitle": "Attività di sicurezza", + "activityDescription": "Accessi recenti, modifiche di sicurezza ed esportazioni di dati del tuo account.", + "activityEmpty": "Nessuna attività di sicurezza.", + "activityLoadError": "Impossibile caricare l'attività di sicurezza.", + "action": { + "newDevice": "Accesso da nuovo dispositivo", + "loginFailed": "Accesso non riuscito", + "login": "Accesso", + "passwordChange": "Password modificata", + "mfa": "Modifica autenticazione a due fattori", + "sessionRevoke": "Sessione disconnessa", + "token": "Modifica token API", + "accountDelete": "Account eliminato", + "dataClear": "Dati azzerati", + "export": "Esportazione dati", + "other": "Evento di sicurezza" + } + } }, "admin": { "title": "Administration", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Promemoria misurazione", "eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.", "eventMedicationIntakeSync": "Sincronizzazione assunzioni", - "eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata." + "eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.", + "security": { + "newDeviceTitle": "Nuovo accesso al tuo account", + "newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.", + "unknownLocation": "posizione sconosciuta" + } }, "i18n": { "maintainershipBanner": { diff --git a/messages/pl.json b/messages/pl.json index 566625aa1..0232877e1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -263,7 +263,8 @@ "passkeySetupLater": "Passkey możesz skonfigurować później w ustawieniach.", "passwordMinLength": "Min. 12 znaków", "passwordTooShort": "Hasło musi mieć co najmniej {minLength} znaków.", - "privacyPolicy": "Polityka prywatności" + "privacyPolicy": "Polityka prywatności", + "passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne." }, "dashboard": { "title": "Pulpit", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", - "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej." + "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.", + "security": { + "sessionsTitle": "Aktywne sesje", + "sessionsDescription": "Urządzenia i przeglądarki obecnie zalogowane na Twoje konto.", + "sessionsLoadError": "Nie udało się załadować aktywnych sesji.", + "sessionsEmpty": "Brak aktywnych sesji.", + "currentSession": "To urządzenie", + "lastActive": "Ostatnia aktywność {time}", + "unknownLocation": "Nieznana lokalizacja", + "revokeSession": "Wyloguj", + "revokeError": "Nie udało się zrealizować żądania. Spróbuj ponownie.", + "signOutEverywhere": "Wyloguj na pozostałych urządzeniach", + "signOutEverywhereDone": "Wylogowano {count} inną/e sesję/e.", + "activityTitle": "Aktywność zabezpieczeń", + "activityDescription": "Ostatnie logowania, zmiany zabezpieczeń i eksporty danych Twojego konta.", + "activityEmpty": "Brak aktywności zabezpieczeń.", + "activityLoadError": "Nie udało się załadować aktywności zabezpieczeń.", + "action": { + "newDevice": "Logowanie z nowego urządzenia", + "loginFailed": "Nieudane logowanie", + "login": "Logowanie", + "passwordChange": "Zmieniono hasło", + "mfa": "Zmiana uwierzytelniania dwuskładnikowego", + "sessionRevoke": "Wylogowano sesję", + "token": "Zmiana tokenu API", + "accountDelete": "Konto usunięte", + "dataClear": "Zresetowano dane", + "export": "Eksport danych", + "other": "Zdarzenie zabezpieczeń" + } + } }, "admin": { "title": "Administracja", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Przypomnienie o pomiarze", "eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.", "eventMedicationIntakeSync": "Synchronizacja przyjęć leku", - "eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta." + "eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.", + "security": { + "newDeviceTitle": "Nowe logowanie do Twojego konta", + "newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.", + "unknownLocation": "nieznana lokalizacja" + } }, "i18n": { "maintainershipBanner": { diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index add5d7cd5..2da8adf47 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -13,6 +13,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { destroyAllSessions } from "@/lib/auth/session"; import { NextRequest, NextResponse } from "next/server"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { checkPasswordBreach } from "@/lib/auth/hibp"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; export const POST = apiHandler( async ( @@ -65,6 +67,16 @@ export const POST = apiHandler( ); } + // v1.23 — reject an admin-set password found in a known breach corpus + // (HIBP k-anonymity). Fail-open on an unreachable HIBP. + const breach = await checkPasswordBreach(password); + if (breach?.breached) { + return apiError( + getServerTranslator(locale).t("auth.passwordBreached"), + 422, + ); + } + const passwordHash = await hashPassword(password); await prisma.user.update({ where: { id }, diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 828304e8a..38cf11b42 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -19,6 +19,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { destroyAllSessions, createSession } from "@/lib/auth/session"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { checkPasswordBreach } from "@/lib/auth/hibp"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -73,6 +75,18 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } + // v1.23 — reject a newly chosen password that appears in a known breach + // corpus (HIBP k-anonymity). Fail-open: a null result (HIBP unreachable) + // never blocks the change. Only the user's chosen NEW password is checked — + // existing credentials are never retroactively blocked at login. + const breach = await checkPasswordBreach(newPassword); + if (breach?.breached) { + return apiError( + getServerTranslator(locale).t("auth.passwordBreached"), + 422, + ); + } + const newHash = await hashPassword(newPassword); await prisma.user.update({ where: { id: user.id }, diff --git a/src/lib/auth/__tests__/hibp.test.ts b/src/lib/auth/__tests__/hibp.test.ts new file mode 100644 index 000000000..5d81bd3da --- /dev/null +++ b/src/lib/auth/__tests__/hibp.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createHash } from "node:crypto"; + +vi.mock("@/lib/safe-fetch", () => ({ safeFetch: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ getEvent: () => null })); + +import { checkPasswordBreach } from "../hibp"; +import { safeFetch } from "@/lib/safe-fetch"; + +const PASSWORD = "correct horse battery staple"; +const DIGEST = createHash("sha1") + .update(PASSWORD, "utf8") + .digest("hex") + .toUpperCase(); +const PREFIX = DIGEST.slice(0, 5); +const SUFFIX = DIGEST.slice(5); + +function textResponse(body: string, ok = true): Response { + return { + ok, + text: async () => body, + } as unknown as Response; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("checkPasswordBreach — k-anonymity", () => { + it("sends ONLY the 5-char prefix and never the full hash or suffix", async () => { + vi.mocked(safeFetch).mockResolvedValue( + textResponse(`${SUFFIX}:42\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:9`), + ); + + await checkPasswordBreach(PASSWORD); + + const url = vi.mocked(safeFetch).mock.calls[0][0] as string; + expect(url).toBe(`https://api.pwnedpasswords.com/range/${PREFIX}`); + // The suffix and full digest must never leave the process. + expect(url).not.toContain(SUFFIX); + expect(url).not.toContain(DIGEST); + // Only 5 hex chars after the range/ segment. + const sent = url.split("/range/")[1]; + expect(sent).toHaveLength(5); + expect(sent).toMatch(/^[0-9A-F]{5}$/); + + // Add-Padding header is set so the response size doesn't leak the bucket. + const init = vi.mocked(safeFetch).mock.calls[0][1] as RequestInit; + expect((init.headers as Record)["Add-Padding"]).toBe( + "true", + ); + // requirePublicHost is pinned. + const opts = vi.mocked(safeFetch).mock.calls[0][2] as { + requirePublicHost?: boolean; + }; + expect(opts.requirePublicHost).toBe(true); + }); + + it("flags a breached password from a matching suffix", async () => { + vi.mocked(safeFetch).mockResolvedValue(textResponse(`${SUFFIX}:1337`)); + const res = await checkPasswordBreach(PASSWORD); + expect(res).toEqual({ breached: true, count: 1337 }); + }); + + it("matches the suffix case-insensitively", async () => { + vi.mocked(safeFetch).mockResolvedValue( + textResponse(`${SUFFIX.toLowerCase()}:5`), + ); + const res = await checkPasswordBreach(PASSWORD); + expect(res?.breached).toBe(true); + }); + + it("reports not-breached when the suffix is absent", async () => { + vi.mocked(safeFetch).mockResolvedValue( + textResponse("0000000000000000000000000000000000A:3"), + ); + const res = await checkPasswordBreach(PASSWORD); + expect(res).toEqual({ breached: false, count: 0 }); + }); + + it("fails open (null) on a non-200 response", async () => { + vi.mocked(safeFetch).mockResolvedValue(textResponse("", false)); + expect(await checkPasswordBreach(PASSWORD)).toBeNull(); + }); + + it("fails open (null) when the request throws (HIBP down)", async () => { + vi.mocked(safeFetch).mockRejectedValue(new Error("network")); + expect(await checkPasswordBreach(PASSWORD)).toBeNull(); + }); +}); diff --git a/src/lib/auth/hibp.ts b/src/lib/auth/hibp.ts new file mode 100644 index 000000000..6bc7ec6df --- /dev/null +++ b/src/lib/auth/hibp.ts @@ -0,0 +1,77 @@ +/** + * v1.23 — HaveIBeenPwned password-breach check via the k-anonymity range API. + * + * Privacy contract (the whole point of k-anonymity): the password is SHA-1'd + * locally and ONLY the first 5 hex characters of the uppercase digest leave the + * server. The remaining 35-character suffix is matched against the response + * locally; the full hash and the password itself never cross the network. The + * request carries `Add-Padding: true` so the response size does not leak which + * prefix bucket was queried. + * + * Resilience: any failure (egress disabled, HIBP down, timeout, non-200, + * malformed body) returns `null` — "unknown". Callers treat `null` as + * fail-open and never block on it; only a confirmed breach (`breached: true`) + * is actionable. The check is therefore safe to run inline at password-set + * time without making HIBP a hard dependency of account management. + */ +import { createHash } from "node:crypto"; +import { safeFetch } from "@/lib/safe-fetch"; +import { getEvent } from "@/lib/logging/context"; + +const HIBP_RANGE_URL = "https://api.pwnedpasswords.com/range/"; + +export interface BreachCheckResult { + breached: boolean; + /** Number of times the password appears in the corpus (0 when not found). */ + count: number; +} + +/** + * Returns the breach status of `password`, or `null` when the check could not + * be completed (fail-open). Never throws. + */ +export async function checkPasswordBreach( + password: string, +): Promise { + try { + const digest = createHash("sha1") + .update(password, "utf8") + .digest("hex") + .toUpperCase(); + const prefix = digest.slice(0, 5); + const suffix = digest.slice(5); + + const res = await safeFetch( + `${HIBP_RANGE_URL}${prefix}`, + { + method: "GET", + headers: { "Add-Padding": "true" }, + signal: AbortSignal.timeout(4000), + }, + // The host is a fixed, app-controlled public endpoint, but pin the + // public-host / DNS-rebinding guard anyway — it is a hard egress, and the + // SSRF floor is cheap. + { requirePublicHost: true }, + ); + + if (!res.ok) return null; + + const body = await res.text(); + // Each line: "<35-hex-suffix>:". Compare case-insensitively. + for (const line of body.split("\n")) { + const sep = line.indexOf(":"); + if (sep < 0) continue; + const lineSuffix = line.slice(0, sep).trim().toUpperCase(); + if (lineSuffix === suffix) { + const count = parseInt(line.slice(sep + 1).trim(), 10); + return { breached: true, count: Number.isFinite(count) ? count : 1 }; + } + } + return { breached: false, count: 0 }; + } catch (err) { + getEvent()?.addWarning( + `HIBP breach check unavailable (fail-open): ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } +} From bed65886995e0b46e91ffcc809275ee8290cbb37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:34:31 +0200 Subject: [PATCH 12/34] feat(security): alert on a new-device sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a completed sign-in (password, second-factor, or passkey) from a device or location not seen before, send one notification through the dispatcher cascade, honouring the user's per-channel preferences via a new SECURITY_ALERT event type. Dedupe on a one-way, per-user fingerprint: a SHA-256 over the userId, the coarse User-Agent family, and a country/ASN-level geo signal, stored in the new user_known_devices ledger (migration 0203). The first sighting of a fingerprint alerts and records; later sightings only bump last-seen and stay silent. The raw User-Agent and IP are never persisted — only the salted hash and a coarse label. Registration records its device silently so a fresh account does not alert on its own first session. The whole path is fire-and-forget and never blocks or fails a sign-in. --- .../0203_v123_known_devices/migration.sql | 33 ++++ prisma/schema.prisma | 28 ++++ .../api/auth/passkey/login-verify/route.ts | 4 + src/app/api/auth/register/route.ts | 24 +++ .../auth/__tests__/device-fingerprint.test.ts | 117 ++++++++++++++ src/lib/auth/__tests__/login-alert.test.ts | 133 ++++++++++++++++ src/lib/auth/device-fingerprint.ts | 118 +++++++++++++++ src/lib/auth/login-alert.ts | 143 ++++++++++++++++++ src/lib/auth/login-response.ts | 7 + src/lib/notifications/types.ts | 11 ++ 10 files changed, 618 insertions(+) create mode 100644 prisma/migrations/0203_v123_known_devices/migration.sql create mode 100644 src/lib/auth/__tests__/device-fingerprint.test.ts create mode 100644 src/lib/auth/__tests__/login-alert.test.ts create mode 100644 src/lib/auth/device-fingerprint.ts create mode 100644 src/lib/auth/login-alert.ts diff --git a/prisma/migrations/0203_v123_known_devices/migration.sql b/prisma/migrations/0203_v123_known_devices/migration.sql new file mode 100644 index 000000000..d461c6bb3 --- /dev/null +++ b/prisma/migrations/0203_v123_known_devices/migration.sql @@ -0,0 +1,33 @@ +-- v1.23 — new-device sign-in alert dedupe ledger. +-- +-- One row per (user, coarse device fingerprint). The fingerprint +-- (`device_hash`) is a one-way SHA-256 hash of +-- `userId | normalised-User-Agent | coarse-location-or-asn`. The raw +-- User-Agent and IP are NEVER stored — the table holds only the salted hash +-- (the userId is part of the digest input, so the same browser on two +-- accounts produces two unrelated hashes) plus an optional coarse, +-- human-readable `label` for the security-activity surface (e.g. +-- "Firefox on macOS — Berlin, DE"; the label omits the IP). +-- +-- A successful sign-in upserts on (user_id, device_hash): the first sighting +-- of a hash inserts the row and fires one new-device notification through the +-- dispatcher; subsequent sightings only bump `last_seen_at` and stay silent. + +CREATE TABLE "user_known_devices" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "device_hash" TEXT NOT NULL, + "label" TEXT, + "first_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_known_devices_pkey" PRIMARY KEY ("id") +); + +-- Dedupe is per-user: a coarse fingerprint can never be inserted twice for +-- one account, so "first sighting → alert" is a single unique-constrained +-- upsert with no race window. +CREATE UNIQUE INDEX "user_known_devices_user_id_device_hash_key" ON "user_known_devices"("user_id", "device_hash"); +CREATE INDEX "user_known_devices_user_id_idx" ON "user_known_devices"("user_id"); + +ALTER TABLE "user_known_devices" ADD CONSTRAINT "user_known_devices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf517cb88..9b974e000 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -575,6 +575,7 @@ model User { mfaChallenges MfaChallenge[] mfaRecoveryCodes MfaRecoveryCode[] webauthnMfaCredentials WebauthnMfaCredential[] + knownDevices UserKnownDevice[] measurements Measurement[] medications Medication[] intakeEvents MedicationIntakeEvent[] @@ -875,6 +876,33 @@ model WebauthnMfaCredential { @@map("webauthn_mfa_credentials") } +// v1.23 — login-alert dedupe ledger. One row per (user, coarse device +// fingerprint). The fingerprint is a one-way SHA-256 hash of +// `userId | normalised-User-Agent | coarse-location-or-asn` — never the raw +// User-Agent or IP, so the table holds no directly identifying request +// metadata (`deviceHash` is salted by the userId, so the same browser on two +// accounts yields two unrelated hashes). `label` is a coarse, human-readable +// descriptor for the activity surface (e.g. "Firefox on macOS — Berlin, DE"); +// it intentionally omits the IP. First sighting of a hash fires one new-device +// notification through the dispatcher; later sightings only bump `lastSeenAt`. +model UserKnownDevice { + id String @id @default(cuid()) + userId String @map("user_id") + deviceHash String @map("device_hash") + label String? + firstSeenAt DateTime @default(now()) @map("first_seen_at") + lastSeenAt DateTime @default(now()) @map("last_seen_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Dedupe is per-user: the same coarse fingerprint can never be inserted + // twice for one account, so the "first sighting → alert" decision is a + // single unique-constrained upsert. + @@unique([userId, deviceHash]) + @@index([userId]) + @@map("user_known_devices") +} + model AuthChallenge { id String @id @default(cuid()) userId String? @map("user_id") // null for registration diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 18178dd64..c041ab07f 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -1,5 +1,6 @@ import { verifyAuthentication } from "@/lib/auth/passkey"; import { createSession } from "@/lib/auth/session"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { prisma } from "@/lib/db"; @@ -76,6 +77,9 @@ export const POST = apiHandler(async (request: NextRequest) => { ipAddress: ip, }); + // v1.23 — new-device / new-location alert, fire-and-forget (see finishLogin). + void recordSignInDevice({ userId: user.id, ip, userAgent: ua }); + annotate({ action: { name: "auth.login.passkey" } }); if ( diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index e97fcaebe..fb74e611e 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -6,6 +6,7 @@ import { import { registerSchema } from "@/lib/validations/auth"; import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; import { createSession } from "@/lib/auth/session"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, @@ -19,6 +20,8 @@ import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { checkPasswordBreach } from "@/lib/auth/hibp"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; import { isValidTimezone, resolveServerDefaultTimezone, @@ -121,6 +124,17 @@ export const POST = apiHandler(async (request: NextRequest) => { 422, ); } + + // v1.23 — reject a registration password found in a known breach corpus + // (HIBP k-anonymity). Fail-open on an unreachable HIBP. + const breach = await checkPasswordBreach(password); + if (breach?.breached) { + return apiError( + getServerTranslator(locale).t("auth.passwordBreached"), + 422, + ); + } + const passwordHash = await hashPassword(password); // v1.15.20 — consume the invite LAST, after every other validation, @@ -176,6 +190,16 @@ export const POST = apiHandler(async (request: NextRequest) => { const ua = request.headers.get("user-agent"); await createSession(user.id, true, ip, ua); + // v1.23 — record the registering device silently so the account's first + // login from this same device does not immediately fire a "new device" + // alert. A genuinely new device later still alerts on its first sighting. + void recordSignInDevice({ + userId: user.id, + ip, + userAgent: ua, + alertOnNew: false, + }); + // Stamp the consumer onto the invite (informational, best-effort — // the use itself was already counted atomically above). if (inviteId) { diff --git a/src/lib/auth/__tests__/device-fingerprint.test.ts b/src/lib/auth/__tests__/device-fingerprint.test.ts new file mode 100644 index 000000000..5bfbff2f0 --- /dev/null +++ b/src/lib/auth/__tests__/device-fingerprint.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { + coarseDeviceLabel, + normaliseUserAgent, + coarseLocationSignal, + computeDeviceHash, + maskIp, +} from "../device-fingerprint"; + +const FIREFOX_MAC = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0"; +const FIREFOX_MAC_MINOR = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0"; +const CHROME_WIN = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"; + +describe("coarseDeviceLabel", () => { + it("reduces a UA to family + platform", () => { + expect(coarseDeviceLabel(FIREFOX_MAC)).toBe("Firefox on macOS"); + expect(coarseDeviceLabel(CHROME_WIN)).toBe("Chrome on Windows"); + }); + it("handles a missing UA", () => { + expect(coarseDeviceLabel(null)).toBe("Unknown device"); + expect(coarseDeviceLabel("")).toBe("Unknown device"); + }); +}); + +describe("coarseLocationSignal", () => { + it("reduces a city string to its country token", () => { + expect(coarseLocationSignal("Berlin, DE")).toBe("DE"); + }); + it("returns empty for no location", () => { + expect(coarseLocationSignal(null)).toBe(""); + }); +}); + +describe("computeDeviceHash", () => { + it("is deterministic and a hex SHA-256 (no raw UA/IP leak)", () => { + const h = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + expect(h).toMatch(/^[0-9a-f]{64}$/); + // The hash must not embed the raw UA. + expect(h).not.toContain("Firefox"); + expect(h).not.toContain("Mozilla"); + expect( + computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }), + ).toBe(h); + }); + + it("is invariant to a minor browser-version bump (no false new-device)", () => { + const a = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + const b = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC_MINOR, + coarseSignal: "DE", + }); + expect(a).toBe(b); + }); + + it("is salted per user — same browser, different account ⇒ different hash", () => { + const a = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + const b = computeDeviceHash({ + userId: "u2", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + expect(a).not.toBe(b); + }); + + it("treats a different country as a new device", () => { + const a = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + const b = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "US", + }); + expect(a).not.toBe(b); + }); +}); + +describe("normaliseUserAgent", () => { + it("matches the coarse label so the hash input is stable", () => { + expect(normaliseUserAgent(FIREFOX_MAC)).toBe("Firefox on macOS"); + }); +}); + +describe("maskIp", () => { + it("masks the host portion of an IPv4", () => { + expect(maskIp("203.0.113.42")).toBe("203.0.x.x"); + }); + it("masks an IPv6 down to two hextets", () => { + expect(maskIp("2001:db8:1234:5678::1")).toBe("2001:db8::"); + }); + it("returns null for a blank IP", () => { + expect(maskIp(null)).toBeNull(); + expect(maskIp("")).toBeNull(); + }); +}); diff --git a/src/lib/auth/__tests__/login-alert.test.ts b/src/lib/auth/__tests__/login-alert.test.ts new file mode 100644 index 000000000..f64d43aa8 --- /dev/null +++ b/src/lib/auth/__tests__/login-alert.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + userKnownDevice: { + findUnique: vi.fn(), + update: vi.fn(), + create: vi.fn(), + }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/geo", () => ({ + lookupIpLocation: vi.fn(async () => "Berlin, DE"), + lookupIpAsn: vi.fn(() => null), +})); + +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: vi.fn().mockResolvedValue({ dispatched: true }), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/i18n/server-translator", () => ({ + getServerTranslator: () => ({ t: (k: string) => k }), +})); + +vi.mock("@/lib/logging/context", () => ({ getEvent: () => null })); + +import { recordSignInDevice } from "../login-alert"; +import { prisma } from "@/lib/db"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; + +const UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0"; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "en", + } as never); +}); + +describe("recordSignInDevice — new-device dedupe", () => { + it("first sighting records the device AND fires one alert", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: false, alerted: true }); + expect(prisma.userKnownDevice.create).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + const payload = vi.mocked(dispatchNotification).mock.calls[0][0]; + expect(payload.eventType).toBe("SECURITY_ALERT"); + expect(payload.userId).toBe("u1"); + }); + + it("second sighting of the same device is silent (no alert)", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue({ + id: "kd-1", + } as never); + vi.mocked(prisma.userKnownDevice.update).mockResolvedValue({} as never); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: true, alerted: false }); + expect(prisma.userKnownDevice.update).toHaveBeenCalledTimes(1); + expect(prisma.userKnownDevice.create).not.toHaveBeenCalled(); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("records silently when alertOnNew is false (registration)", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + alertOnNew: false, + }); + + expect(res).toEqual({ known: false, alerted: false }); + expect(prisma.userKnownDevice.create).toHaveBeenCalledTimes(1); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("a unique-index race on create does not double-alert", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockRejectedValue( + new Error("unique violation"), + ); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: true, alerted: false }); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("never persists the raw User-Agent — only the salted hash + coarse label", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); + + await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + const data = vi.mocked(prisma.userKnownDevice.create).mock.calls[0][0].data; + expect(data.deviceHash).toMatch(/^[0-9a-f]{64}$/); + expect(JSON.stringify(data)).not.toContain("Mozilla"); + expect(JSON.stringify(data)).not.toContain("203.0.113.9"); + // The coarse label is the family + platform descriptor. + expect(data.label).toContain("Firefox on macOS"); + }); +}); diff --git a/src/lib/auth/device-fingerprint.ts b/src/lib/auth/device-fingerprint.ts new file mode 100644 index 000000000..ecb645d1f --- /dev/null +++ b/src/lib/auth/device-fingerprint.ts @@ -0,0 +1,118 @@ +/** + * v1.23 — coarse device fingerprinting shared by the active-session list and + * the new-device sign-in alert. + * + * The fingerprint that backs login-alert dedupe is deliberately COARSE and + * one-way: a SHA-256 over `userId | normalised-User-Agent | coarse-signal`. + * The raw User-Agent and IP never leave this module into storage — only the + * salted hash (userId-salted, so the same browser on two accounts hashes + * differently) and a coarse human-readable label. A minor-version UA bump or a + * differently-formatted IP must NOT mint a "new device", so the UA is reduced + * to its family + platform and the geo signal to a country/ASN granularity. + */ +import { createHash } from "node:crypto"; + +/** + * Reduce a User-Agent to a stable family + platform descriptor, e.g. + * "Firefox on macOS". Minor versions, build numbers, and engine noise are + * dropped so a routine browser update does not register as a new device. + * Returns "Unknown device" when the UA is missing or unrecognised. + */ +export function coarseDeviceLabel( + userAgent: string | null | undefined, +): string { + const ua = (userAgent ?? "").trim(); + if (!ua) return "Unknown device"; + + const browser = /\bEdg(?:e|A|iOS)?\//.test(ua) + ? "Edge" + : /\bOPR\/|\bOpera\b/.test(ua) + ? "Opera" + : /\bFirefox\//.test(ua) + ? "Firefox" + : /\bChrome\//.test(ua) && !/\bChromium\//.test(ua) + ? "Chrome" + : /\bChromium\//.test(ua) + ? "Chromium" + : /\bSafari\//.test(ua) && /\bVersion\//.test(ua) + ? "Safari" + : /HealthLog-iOS/i.test(ua) + ? "HealthLog iOS app" + : null; + + const platform = /\bWindows NT\b/.test(ua) + ? "Windows" + : /\b(iPhone|iPad|iPod)\b/.test(ua) + ? "iOS" + : /\bMac OS X\b|\bMacintosh\b/.test(ua) + ? "macOS" + : /\bAndroid\b/.test(ua) + ? "Android" + : /\bLinux\b/.test(ua) + ? "Linux" + : null; + + if (browser && platform) return `${browser} on ${platform}`; + if (browser) return browser; + if (platform) return platform; + return "Unknown device"; +} + +/** + * Reduce a User-Agent to the stable string fed into the dedupe hash. Uses the + * coarse label so the hash is invariant to minor-version churn. + */ +export function normaliseUserAgent( + userAgent: string | null | undefined, +): string { + return coarseDeviceLabel(userAgent); +} + +/** + * Reduce a resolved geo string to a coarse country-level signal, e.g. + * "Berlin, DE" → "DE". Falls back to the whole string when there is no + * trailing country token, and to "" when no location resolved. Kept coarse so + * a user moving across a city/region does not register as a new device while a + * cross-country sign-in still does. + */ +export function coarseLocationSignal( + location: string | null | undefined, +): string { + const loc = (location ?? "").trim(); + if (!loc) return ""; + const parts = loc.split(",").map((p) => p.trim()); + return parts[parts.length - 1] || loc; +} + +/** + * One-way, per-user device fingerprint. The userId is part of the digest so + * the same browser on two accounts yields two unrelated hashes (no + * cross-account correlation), and the raw inputs are never recoverable. + */ +export function computeDeviceHash(params: { + userId: string; + userAgent: string | null | undefined; + coarseSignal: string; +}): string { + const material = `${params.userId}|${normaliseUserAgent(params.userAgent)}|${params.coarseSignal}`; + return createHash("sha256").update(material).digest("hex"); +} + +/** + * Mask an IP for the user-facing security surfaces: keep enough to recognise + * the network, drop the host. IPv4 → first two octets; IPv6 → first two + * hextets. Returns null for a null/blank input. + */ +export function maskIp(ip: string | null | undefined): string | null { + const raw = (ip ?? "").trim(); + if (!raw) return null; + if (raw.includes(":")) { + const groups = raw.split(":").filter((g) => g.length > 0); + return `${groups.slice(0, 2).join(":")}::`; + } + const octets = raw.split("."); + if (octets.length === 4) { + return `${octets[0]}.${octets[1]}.x.x`; + } + return raw; +} diff --git a/src/lib/auth/login-alert.ts b/src/lib/auth/login-alert.ts new file mode 100644 index 000000000..4da185087 --- /dev/null +++ b/src/lib/auth/login-alert.ts @@ -0,0 +1,143 @@ +/** + * v1.23 — new-device / new-location sign-in alert. + * + * Called on every completed sign-in (password, MFA-verify, passkey). Computes + * a coarse, one-way device fingerprint (see `device-fingerprint.ts`) and: + * - first sighting of the fingerprint → record it AND fire one notification + * through the dispatcher cascade (respecting the user's per-channel prefs), + * - any later sighting → bump `lastSeenAt` only, stay silent (dedupe). + * + * The whole thing is best-effort and is invoked fire-and-forget from the login + * paths: a failure here must never block or fail a sign-in. The raw IP/UA are + * never persisted — only the salted hash + a coarse label (and the audit row, + * which already records IP/geo for `auth.*` events). + */ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { lookupIpLocation, lookupIpAsn } from "@/lib/geo"; +import { + coarseDeviceLabel, + coarseLocationSignal, + computeDeviceHash, +} from "@/lib/auth/device-fingerprint"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { getEvent } from "@/lib/logging/context"; + +function isLocale(value: string | null | undefined): value is Locale { + return ( + value === "de" || + value === "en" || + value === "fr" || + value === "es" || + value === "it" || + value === "pl" + ); +} + +export interface RecordSignInResult { + known: boolean; + alerted: boolean; +} + +export async function recordSignInDevice(params: { + userId: string; + ip: string | null; + userAgent: string | null; + /** + * When false the device is recorded silently with no notification — used by + * account registration, where the very first device should not trigger a + * "new device" alert the moment the account is created. + */ + alertOnNew?: boolean; +}): Promise { + const { userId, ip, userAgent } = params; + const alertOnNew = params.alertOnNew ?? true; + + try { + // Coarse geo signal — best-effort. The location resolver is online-first + // with its own timeout; a miss falls back to the ASN number so a device + // on the same network still dedupes even without a city. + let location: string | null = null; + try { + location = await lookupIpLocation(ip); + } catch { + location = null; + } + const coarseSignal = + coarseLocationSignal(location) || lookupIpAsn(ip)?.asn?.toString() || ""; + + const deviceHash = computeDeviceHash({ userId, userAgent, coarseSignal }); + const deviceLabel = coarseDeviceLabel(userAgent); + const storedLabel = location ? `${deviceLabel} — ${location}` : deviceLabel; + + const existing = await prisma.userKnownDevice.findUnique({ + where: { userId_deviceHash: { userId, deviceHash } }, + select: { id: true }, + }); + + if (existing) { + await prisma.userKnownDevice + .update({ + where: { id: existing.id }, + data: { lastSeenAt: new Date() }, + }) + .catch(() => {}); + return { known: true, alerted: false }; + } + + // First sighting. Insert; if a concurrent first-login races us to the + // unique (userId, deviceHash) index, swallow it and treat as already known + // so we never double-alert. + try { + await prisma.userKnownDevice.create({ + data: { userId, deviceHash, label: storedLabel }, + }); + } catch { + return { known: true, alerted: false }; + } + + if (!alertOnNew) return { known: false, alerted: false }; + + await auditLog("auth.login.new_device", { + userId, + ipAddress: ip, + details: { device: deviceLabel }, + }); + + // Localise against the recipient's own locale, then fan out through the + // dispatcher (which applies the user's per-channel SECURITY_ALERT prefs). + let locale: Locale = defaultLocale; + try { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + if (isLocale(row?.locale)) locale = row.locale; + } catch { + locale = defaultLocale; + } + const { t } = getServerTranslator(locale); + const locationText = + location ?? t("notifications.security.unknownLocation"); + + await dispatchNotification({ + eventType: "SECURITY_ALERT", + userId, + title: t("notifications.security.newDeviceTitle"), + message: t("notifications.security.newDeviceBody", { + device: deviceLabel, + location: locationText, + }), + urgent: true, + }); + + return { known: false, alerted: true }; + } catch (err) { + getEvent()?.addWarning( + `recordSignInDevice failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return { known: false, alerted: false }; + } +} diff --git a/src/lib/auth/login-response.ts b/src/lib/auth/login-response.ts index 519c1f81d..d0998afd8 100644 --- a/src/lib/auth/login-response.ts +++ b/src/lib/auth/login-response.ts @@ -26,6 +26,7 @@ import { isCookielessNativeCaller, } from "@/lib/auth/native-client"; import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; import type { User } from "@/generated/prisma/client"; export interface FinishLoginParams { @@ -54,6 +55,12 @@ export async function finishLogin( ): Promise { const { user, request, ip, userAgent, source, mfaVerified } = params; + // v1.23 — new-device / new-location alert. Fire-and-forget: never let the + // device ledger or the dispatcher add latency to — or fail — a sign-in. Runs + // for every transport (web cookie + native bundle) since the factor checks + // are already done by the time finishLogin is reached. + void recordSignInDevice({ userId: user.id, ip, userAgent }); + if ( shouldIssueBearerToken(request.headers) || isNativeClientRequest(request.headers) diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index b338b566c..860e78af6 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -94,6 +94,13 @@ export const EVENT_TYPES = [ // user-visible Telegram message on every dose would be noise). Routed // through the dedicated intake-sync sender, not the alert cascade. "MEDICATION_INTAKE_SYNC", + // v1.23 — account-security alert (new-device / new-location sign-in). Fired + // by the login completion path on the first sighting of a coarse device + // fingerprint. ON by default at the channel layer: a sign-in the user did + // not make is exactly the case they need to hear about, so silence-by-default + // would defeat the feature. An explicit per-channel `NotificationPreference` + // row still wins for a user who opts out. + "SECURITY_ALERT", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -151,6 +158,10 @@ export const EVENT_DEFAULT_ENABLED: Record = { // toggle: this is plumbing that keeps the user's own devices coherent, // not a notification the user chooses to receive. MEDICATION_INTAKE_SYNC: true, + // v1.23 — ON by default; a new-device sign-in is a security event the user + // needs surfaced. An explicit per-channel `NotificationPreference` row + // (enabled=false) still suppresses it for a user who opts out. + SECURITY_ALERT: true, }; export const CHANNEL_TYPE_LABELS: Record = { From 9269043e91b483b3bbac799fa12269a940fdf98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:34:43 +0200 Subject: [PATCH 13/34] feat(security): list active sessions, sign out everywhere, and show security activity Add the user-facing account-security surface in Settings: - Active sessions (closes #64): GET /api/auth/me/sessions lists each browser login with a coarse device label, masked IP, resolved location, the sliding last-active stamp, and the current-session marker. DELETE the collection signs out everywhere else (keeps the current session, revokes other sessions and native refresh tokens, leaves API tokens alone); DELETE one revokes a single session, scoped to the owner so a foreign id 404s. - A sliding last-active stamp on the session, written throttled and fire-and-forget off the request-resolution path. - Security activity: GET /api/auth/me/security-activity, the shared read over the audit log filtered to sign-ins, second-factor changes, password changes, session revocations, exports, and deletions, with masked IPs. OpenAPI regenerated for the new endpoints. --- docs/api/openapi.yaml | 246 ++++++++++++++++++ .../api/auth/me/security-activity/route.ts | 70 +++++ src/app/api/auth/me/sessions/[id]/route.ts | 41 +++ .../auth/me/sessions/__tests__/route.test.ts | 142 ++++++++++ src/app/api/auth/me/sessions/route.ts | 105 ++++++++ .../settings/account-section/index.tsx | 8 + .../settings/security-activity-card.tsx | 125 +++++++++ .../settings/security-sessions-card.tsx | 183 +++++++++++++ .../auth/__tests__/security-activity.test.ts | 64 +++++ src/lib/auth/security-activity.ts | 41 +++ src/lib/auth/session.ts | 65 +++++ src/lib/openapi/routes/auth.ts | 144 +++++++++- src/lib/query-keys/auth.ts | 15 ++ .../sessions-and-known-devices.test.ts | 131 ++++++++++ 14 files changed, 1379 insertions(+), 1 deletion(-) create mode 100644 src/app/api/auth/me/security-activity/route.ts create mode 100644 src/app/api/auth/me/sessions/[id]/route.ts create mode 100644 src/app/api/auth/me/sessions/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/sessions/route.ts create mode 100644 src/components/settings/security-activity-card.tsx create mode 100644 src/components/settings/security-sessions-card.tsx create mode 100644 src/lib/auth/__tests__/security-activity.test.ts create mode 100644 src/lib/auth/security-activity.ts create mode 100644 tests/integration/sessions-and-known-devices.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4055a7446..648c082e6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1554,6 +1554,81 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me/sessions: + get: + tags: + - Auth + summary: List active web sessions + description: "v1.23 — the user-facing active-session list (issue #64). One row per browser login with a coarse device + label, masked IP, resolved location, sliding last-active time, and the current-session marker. Distinct from + /api/auth/me/devices (notification devices)." + responses: + "200": + description: Active sessions for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/SessionListEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + delete: + tags: + - Auth + summary: Sign out everywhere else + description: v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current + session. API tokens are not touched (manage those under /settings/api-tokens). + responses: + "200": + description: Other sessions revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/SignOutEverywhereEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/sessions/{id}: + delete: + tags: + - Auth + summary: Revoke a single web session + description: v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never + another user's row). + responses: + "200": + description: Session revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/SessionRevokeEnvelope" + "401": *a1 + "404": + description: Session not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/auth/me/security-activity: + get: + tags: + - Auth + summary: List recent account-security activity + description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with + timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the + AuditLog store; no event detail bodies are surfaced." + responses: + "200": + description: Recent security events for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/SecurityActivityEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/sync/state: get: tags: @@ -11011,6 +11086,177 @@ components: - data - error additionalProperties: false + SessionListEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SessionListResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SessionListResponse: + type: object + properties: + sessions: + type: array + items: + type: object + properties: + id: + type: string + device: + type: string + description: Coarse device label derived from the User-Agent. + ipMasked: + anyOf: + - type: string + - type: "null" + description: IP with the host portion masked — never the full address. + location: + anyOf: + - type: string + - type: "null" + description: Resolved coarse location, when available. + lastActiveAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + isCurrent: + type: boolean + description: True for the session making this request. + required: + - id + - device + - ipMasked + - location + - lastActiveAt + - createdAt + - isCurrent + additionalProperties: false + required: + - sessions + additionalProperties: false + SignOutEverywhereEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SignOutEverywhereResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SignOutEverywhereResponse: + type: object + properties: + sessionsRevoked: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Number of OTHER sessions removed (the current one is kept). + required: + - sessionsRevoked + additionalProperties: false + SessionRevokeEnvelope: + type: object + properties: + data: + type: object + properties: + revoked: + type: boolean + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SecurityActivityEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SecurityActivityResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SecurityActivityResponse: + type: object + properties: + events: + type: array + items: + type: object + properties: + action: + type: string + description: Audit action name, e.g. auth.login.password. + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + location: + anyOf: + - type: string + - type: "null" + ipMasked: + anyOf: + - type: string + - type: "null" + description: Host-masked IP. + carrier: + anyOf: + - type: string + - type: "null" + required: + - action + - createdAt + - location + - ipMasked + - carrier + additionalProperties: false + required: + - events + additionalProperties: false SyncStateEnvelope: type: object properties: diff --git a/src/app/api/auth/me/security-activity/route.ts b/src/app/api/auth/me/security-activity/route.ts new file mode 100644 index 000000000..b7966da2e --- /dev/null +++ b/src/app/api/auth/me/security-activity/route.ts @@ -0,0 +1,70 @@ +/** + * GET /api/auth/me/security-activity + * + * v1.23 — the SHARED user-facing security-activity feed. Returns the caller's + * recent account-security audit events (logins, MFA, password change, token / + * session revocations, exports, deletions) with timestamp + resolved location + * + MASKED IP. The IP is never returned in full and no event detail body is + * surfaced — only the action name, time, and coarse geo. Consumed by the + * account-security surface and the privacy dashboard. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { securityActivityWhere } from "@/lib/auth/security-activity"; +import { maskIp } from "@/lib/auth/device-fingerprint"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +export interface SecurityActivityDTO { + action: string; + createdAt: string; + location: string | null; + ipMasked: string | null; + carrier: string | null; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const limitParam = Number( + request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT, + ); + const limit = + Number.isFinite(limitParam) && limitParam > 0 + ? Math.min(Math.floor(limitParam), MAX_LIMIT) + : DEFAULT_LIMIT; + + const rows = await prisma.auditLog.findMany({ + where: securityActivityWhere(user.id), + orderBy: { createdAt: "desc" }, + take: limit, + select: { + action: true, + createdAt: true, + location: true, + ipAddress: true, + carrier: true, + }, + }); + + const events: SecurityActivityDTO[] = rows.map((r) => ({ + action: r.action, + createdAt: r.createdAt.toISOString(), + location: r.location ?? null, + ipMasked: maskIp(r.ipAddress), + carrier: r.carrier ?? null, + })); + + annotate({ + action: { name: "auth.security_activity.list" }, + meta: { event_count: events.length }, + }); + + return apiSuccess({ events }); +}); diff --git a/src/app/api/auth/me/sessions/[id]/route.ts b/src/app/api/auth/me/sessions/[id]/route.ts new file mode 100644 index 000000000..4a98aed9d --- /dev/null +++ b/src/app/api/auth/me/sessions/[id]/route.ts @@ -0,0 +1,41 @@ +/** + * DELETE /api/auth/me/sessions/[id] — revoke a single active web session. + * + * v1.23 — the deletion is scoped to the authenticated user (the Prisma `where` + * narrows on both id AND userId), so a caller can never revoke another user's + * session by guessing an id. A 404 is returned for a non-existent or + * not-owned id so the surface cannot probe foreign session ids. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { destroySessionById } from "@/lib/auth/session"; + +export const dynamic = "force-dynamic"; + +export const DELETE = apiHandler( + async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { + const { user, session } = await requireAuth(); + const { id } = await ctx.params; + + const removed = await destroySessionById(user.id, id); + if (!removed) { + return apiError("Session not found", 404); + } + + await auditLog("auth.session.revoke", { + userId: user.id, + ipAddress: getClientIp(request), + details: { self: id === session.id }, + }); + + annotate({ + action: { name: "auth.session.revoke" }, + meta: { self: id === session.id }, + }); + + return apiSuccess({ revoked: true }); + }, +); diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts new file mode 100644 index 000000000..57ad51f46 --- /dev/null +++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { session: { findMany: vi.fn() } }, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), + destroyOtherSessions: vi.fn(), + destroySessionById: vi.fn(), +})); + +vi.mock("@/lib/geo", () => ({ + lookupIpLocation: vi.fn(async () => "Berlin, DE"), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, DELETE } from "../route"; +import { DELETE as DELETE_ONE } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { + getSession, + destroyOtherSessions, + destroySessionById, +} from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-current", expiresAt: new Date(Date.now() + 3.6e6) }, + user: { id: "user-1", username: "u", role: "USER" as const }, +}; + +function del(): NextRequest { + return new NextRequest("http://localhost/api/auth/me/sessions", { + method: "DELETE", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/auth/me/sessions", () => { + it("401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET(); + expect(res.status).toBe(401); + }); + + it("lists sessions with masked IP, location, and the current marker", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.session.findMany).mockResolvedValue([ + { + id: "sess-current", + ipAddress: "203.0.113.7", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0", + lastActiveAt: new Date(), + createdAt: new Date(), + }, + { + id: "sess-other", + ipAddress: "198.51.100.4", + userAgent: + "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36", + lastActiveAt: null, + createdAt: new Date(), + }, + ] as never); + + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + const list = body.data.sessions; + expect(list).toHaveLength(2); + expect(list[0]).toMatchObject({ + id: "sess-current", + device: "Firefox on macOS", + ipMasked: "203.0.x.x", + location: "Berlin, DE", + isCurrent: true, + }); + expect(list[1].isCurrent).toBe(false); + // The full IP never appears in the response body. + expect(JSON.stringify(body)).not.toContain("203.0.113.7"); + }); +}); + +describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { + it("revokes other sessions and reports the count", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(destroyOtherSessions).mockResolvedValue({ sessionsRevoked: 3 }); + + const res = await DELETE(del()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.sessionsRevoked).toBe(3); + expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", "sess-current"); + }); +}); + +describe("DELETE /api/auth/me/sessions/[id]", () => { + function ctx(id: string) { + return { params: Promise.resolve({ id }) }; + } + function delReq(id: string): NextRequest { + return new NextRequest(`http://localhost/api/auth/me/sessions/${id}`, { + method: "DELETE", + }); + } + + it("revokes a single owned session", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(destroySessionById).mockResolvedValue(true); + const res = await DELETE_ONE(delReq("sess-other"), ctx("sess-other")); + expect(res.status).toBe(200); + expect(destroySessionById).toHaveBeenCalledWith("user-1", "sess-other"); + }); + + it("404 for a non-existent or not-owned id", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(destroySessionById).mockResolvedValue(false); + const res = await DELETE_ONE(delReq("nope"), ctx("nope")); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts new file mode 100644 index 000000000..bb3fcd05e --- /dev/null +++ b/src/app/api/auth/me/sessions/route.ts @@ -0,0 +1,105 @@ +/** + * GET /api/auth/me/sessions — list the user's active web sessions. + * DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER + * session (and native refresh tokens), keeping + * the caller's current session. Closes #64. + * + * v1.23 — the user-facing session/device-management surface. Distinct from + * `/api/auth/me/devices`, which lists APNs / Web-Push notification devices; + * this lists the authenticated `Session` rows (one per browser login). The list + * carries the masked IP + resolved coarse location + a coarse device label + + * the sliding `lastActiveAt` — never the full IP, never a credential. The + * caller's own row is flagged `isCurrent` so the UI can mark "this device". + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { getClientIp } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { destroyOtherSessions } from "@/lib/auth/session"; +import { lookupIpLocation } from "@/lib/geo"; +import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint"; + +export const dynamic = "force-dynamic"; + +export interface SessionDTO { + id: string; + device: string; + ipMasked: string | null; + location: string | null; + lastActiveAt: string | null; + createdAt: string; + isCurrent: boolean; +} + +export const GET = apiHandler(async () => { + const { user, session } = await requireAuth(); + + const sessions = await prisma.session.findMany({ + where: { userId: user.id, expiresAt: { gt: new Date() } }, + orderBy: { lastActiveAt: { sort: "desc", nulls: "last" } }, + select: { + id: true, + ipAddress: true, + userAgent: true, + lastActiveAt: true, + createdAt: true, + }, + }); + + // Resolve location once per distinct IP (the resolver caches internally, but + // de-duping keeps the await count to the number of unique networks). + const uniqueIps = Array.from( + new Set( + sessions.map((s) => s.ipAddress).filter((ip): ip is string => !!ip), + ), + ); + const locationByIp = new Map(); + await Promise.all( + uniqueIps.map(async (ip) => { + try { + locationByIp.set(ip, await lookupIpLocation(ip)); + } catch { + locationByIp.set(ip, null); + } + }), + ); + + const result: SessionDTO[] = sessions.map((s) => ({ + id: s.id, + device: coarseDeviceLabel(s.userAgent), + ipMasked: maskIp(s.ipAddress), + location: s.ipAddress ? (locationByIp.get(s.ipAddress) ?? null) : null, + lastActiveAt: s.lastActiveAt ? s.lastActiveAt.toISOString() : null, + createdAt: s.createdAt.toISOString(), + isCurrent: s.id === session.id, + })); + + annotate({ + action: { name: "auth.session.list" }, + meta: { session_count: result.length }, + }); + + return apiSuccess({ sessions: result }); +}); + +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user, session } = await requireAuth(); + + const { sessionsRevoked } = await destroyOtherSessions(user.id, session.id); + + await auditLog("auth.session.revoke_others", { + userId: user.id, + ipAddress: getClientIp(request), + details: { sessionsRevoked }, + }); + + annotate({ + action: { name: "auth.session.revoke_others" }, + meta: { sessions_revoked: sessionsRevoked }, + }); + + return apiSuccess({ sessionsRevoked }); +}); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 4fc5df757..7ba648646 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -39,6 +39,8 @@ import { } from "./account-section-utils"; import { PasskeyListSection } from "./passkey-list-section"; import { AvatarSection } from "./avatar-section"; +import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; +import { SecurityActivityCard } from "@/components/settings/security-activity-card"; export { resolveInitialTimezone } from "./account-section-utils"; @@ -589,6 +591,12 @@ export function AccountSection() { + {/* v1.23 — active-session management (issue #64) + the security-activity + feed. Both sit in the account/security block under the credential + controls above. */} + + + {/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings → Erweitert. It is a maintenance / reset action, not a profile or security control, so it sits beside Research Mode + the danger zone. */} diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx new file mode 100644 index 000000000..7f7426984 --- /dev/null +++ b/src/components/settings/security-activity-card.tsx @@ -0,0 +1,125 @@ +"use client"; + +/** + * v1.23 — Settings → account → "Security activity". + * + * A read-only feed of the user's recent account-security events (sign-ins, MFA + * changes, password changes, session revocations, exports, deletions) from the + * shared `GET /api/auth/me/security-activity` endpoint. Each row shows a + * readable action label, the resolved location, the masked IP, and the time. + */ +import { useQuery } from "@tanstack/react-query"; +import { Loader2, ShieldCheck } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; + +interface ActivityRow { + action: string; + createdAt: string; + location: string | null; + ipMasked: string | null; + carrier: string | null; +} + +/** Map an audit action onto a stable, translatable label key. */ +function actionLabelKey(action: string): string { + if (action === "auth.login.new_device") + return "settings.security.action.newDevice"; + if (action === "auth.login.failed") + return "settings.security.action.loginFailed"; + if (action.startsWith("auth.login") || action === "auth.register") + return "settings.security.action.login"; + if (action.startsWith("auth.password")) + return "settings.security.action.passwordChange"; + if (action.startsWith("auth.mfa")) return "settings.security.action.mfa"; + if (action.startsWith("auth.session")) + return "settings.security.action.sessionRevoke"; + if (action.startsWith("auth.token") || action.startsWith("auth.bearer")) + return "settings.security.action.token"; + if (action === "user.account.delete") + return "settings.security.action.accountDelete"; + if (action === "user.data.clear") return "settings.security.action.dataClear"; + if (action.includes("export")) return "settings.security.action.export"; + return "settings.security.action.other"; +} + +export function SecurityActivityCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.securityActivity(), + queryFn: () => + apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"), + enabled: isAuthenticated, + }); + + const events = data?.events ?? []; + + return ( + + +
+

+ {t("settings.security.activityDescription")} +

+ + {isLoading && ( + + )} + + {isError && ( +

+ {t("settings.security.activityLoadError")} +

+ )} + + {!isLoading && !isError && events.length === 0 && ( +

+ {t("settings.security.activityEmpty")} +

+ )} + + {events.length > 0 && ( +
    + {events.map((e, idx) => ( +
  • +
    +

    + {t(actionLabelKey(e.action))} +

    +

    + {[ + e.location ?? t("settings.security.unknownLocation"), + e.ipMasked, + ] + .filter(Boolean) + .join(" · ")} +

    +
    +

    + {new Date(e.createdAt).toLocaleString()} +

    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx new file mode 100644 index 000000000..77f98cabb --- /dev/null +++ b/src/components/settings/security-sessions-card.tsx @@ -0,0 +1,183 @@ +"use client"; + +/** + * v1.23 — Settings → account → "Active sessions" (issue #64). + * + * Lists the user's active web sessions (device label, masked IP, resolved + * location, last-active time, current-device marker) and offers a per-session + * "sign out" plus a "sign out everywhere else" that revokes every other + * session (and native device logins) while keeping the current one. All reads + * + writes route through the centralised query-key factory. + */ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Loader2, MonitorSmartphone } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet, apiDelete } from "@/lib/api/api-fetch"; + +interface SessionRow { + id: string; + device: string; + ipMasked: string | null; + location: string | null; + lastActiveAt: string | null; + createdAt: string; + isCurrent: boolean; +} + +export function SecuritySessionsCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [status, setStatus] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.sessions(), + queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"), + enabled: isAuthenticated, + }); + + const revokeOne = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/auth/me/sessions/${id}`), + onSuccess: () => { + setStatus(null); + void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.securityActivity(), + }); + }, + onError: () => setStatus(t("settings.security.revokeError")), + }); + + const revokeOthers = useMutation({ + mutationFn: () => + apiDelete<{ sessionsRevoked: number }>("/api/auth/me/sessions"), + onSuccess: (res) => { + setStatus( + t("settings.security.signOutEverywhereDone", { + count: res?.sessionsRevoked ?? 0, + }), + ); + void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.securityActivity(), + }); + }, + onError: () => setStatus(t("settings.security.revokeError")), + }); + + const sessions = data?.sessions ?? []; + const hasOthers = sessions.some((s) => !s.isCurrent); + + return ( + + +
+

+ {t("settings.security.sessionsDescription")} +

+ + {isLoading && ( + + )} + + {isError && ( +

+ {t("settings.security.sessionsLoadError")} +

+ )} + + {!isLoading && !isError && sessions.length === 0 && ( +

+ {t("settings.security.sessionsEmpty")} +

+ )} + + {sessions.length > 0 && ( +
    + {sessions.map((s) => ( +
  • +
    +

    + {s.device} + {s.isCurrent && ( + + {t("settings.security.currentSession")} + + )} +

    +

    + {[ + s.location ?? t("settings.security.unknownLocation"), + s.ipMasked, + ] + .filter(Boolean) + .join(" · ")} +

    + {s.lastActiveAt && ( +

    + {t("settings.security.lastActive", { + time: new Date(s.lastActiveAt).toLocaleString(), + })} +

    + )} +
    + {!s.isCurrent && ( + + )} +
  • + ))} +
+ )} + + {hasOthers && ( +
+ +
+ )} + + {status && ( +

+ {status} +

+ )} +
+
+ ); +} diff --git a/src/lib/auth/__tests__/security-activity.test.ts b/src/lib/auth/__tests__/security-activity.test.ts new file mode 100644 index 000000000..7575e15f0 --- /dev/null +++ b/src/lib/auth/__tests__/security-activity.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { + matchesSecurityActivity, + securityActivityWhere, +} from "../security-activity"; + +describe("matchesSecurityActivity", () => { + it("includes every auth.* event", () => { + for (const a of [ + "auth.login.password", + "auth.login.failed", + "auth.login.new_device", + "auth.mfa.verify", + "auth.password.change", + "auth.session.revoke", + "auth.session.revoke_others", + "auth.token.autoissue.native", + "auth.bearer.success", + "auth.register", + ]) { + expect(matchesSecurityActivity(a)).toBe(true); + } + }); + + it("includes export actions", () => { + for (const a of [ + "export.download", + "health-record.export", + "user.export.full-backup", + "user.export.measurements", + ]) { + expect(matchesSecurityActivity(a)).toBe(true); + } + }); + + it("includes the erasure actions", () => { + expect(matchesSecurityActivity("user.account.delete")).toBe(true); + expect(matchesSecurityActivity("user.data.clear")).toBe(true); + }); + + it("excludes unrelated audit actions", () => { + for (const a of [ + "measurement.create", + "medication.intake.bulk", + "coach.budget.exceeded", + "withings.sync.ok", + "consent.ai.web", + ]) { + expect(matchesSecurityActivity(a)).toBe(false); + } + }); +}); + +describe("securityActivityWhere", () => { + it("scopes by userId and mirrors the predicate clauses", () => { + const where = securityActivityWhere("user-1"); + expect(where.userId).toBe("user-1"); + expect(where.OR).toEqual([ + { action: { startsWith: "auth." } }, + { action: { contains: "export" } }, + { action: { in: ["user.account.delete", "user.data.clear"] } }, + ]); + }); +}); diff --git a/src/lib/auth/security-activity.ts b/src/lib/auth/security-activity.ts new file mode 100644 index 000000000..f687a30f3 --- /dev/null +++ b/src/lib/auth/security-activity.ts @@ -0,0 +1,41 @@ +/** + * v1.23 — security-activity feed. The SHARED read both the account-security + * surface and the data-sovereignty privacy dashboard consume: a user-scoped, + * read-only window onto the `AuditLog` rows that matter for account security — + * logins, MFA events, password changes, token/session revocations, exports, + * and deletions. No new storage: it reuses the existing + * `@@index([userId, action, createdAt(sort: Desc)])`. + */ + +/** + * Whether an audit action belongs in the user-facing security-activity feed. + * Pure so the contract can be unit-tested without a DB: + * - every `auth.*` event (login, mfa, password, token, bearer, session, + * register), + * - every export action (`export.download`, `health-record.export`, + * `user.export.*` — all contain "export"), + * - the two erasure actions (`user.account.delete`, `user.data.clear`). + */ +export function matchesSecurityActivity(action: string): boolean { + return ( + action.startsWith("auth.") || + action.includes("export") || + action === "user.account.delete" || + action === "user.data.clear" + ); +} + +/** + * Prisma `where` mirror of `matchesSecurityActivity`, scoped to one user. Kept + * alongside the predicate so the two never drift. + */ +export function securityActivityWhere(userId: string) { + return { + userId, + OR: [ + { action: { startsWith: "auth." } }, + { action: { contains: "export" } }, + { action: { in: ["user.account.delete", "user.data.clear"] } }, + ], + }; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 7a2c73e77..83295b4b8 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -8,6 +8,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; const SESSION_COOKIE = "healthlog_session"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +// v1.23 — throttle window for the user-facing active-session list's "last +// seen" stamp. A write only happens when the prior stamp is older than this, +// so the high-churn `getSession` path costs at most one extra UPDATE per +// session per window rather than one per request. +const LAST_ACTIVE_THROTTLE_MS = 5 * 60 * 1000; // 5 minutes + /** * v1.4.22 C4 — flag cookie mirroring the user's `onboardingCompletedAt` * status. Set to `"pending"` while the field is null and cleared once @@ -147,6 +153,22 @@ export async function getSession(): Promise<{ }); } + // v1.23 — sliding "last seen" stamp for the active-session list. Throttled + // (only when the prior stamp is stale) and fire-and-forget so it never adds + // latency or a failure mode to the request-resolution path. + const lastActive = session.lastActiveAt; + if ( + !lastActive || + lastActive.getTime() < Date.now() - LAST_ACTIVE_THROTTLE_MS + ) { + void prisma.session + .update({ + where: { id: session.id }, + data: { lastActiveAt: new Date() }, + }) + .catch(() => {}); + } + return { session: { id: session.id, expiresAt: session.expiresAt }, user: session.user, @@ -191,3 +213,46 @@ export async function destroyAllSessions(userId: string): Promise { }), ]); } + +/** + * v1.23 — "sign out everywhere" for the user-facing active-session surface + * (issue #64). Distinct from `destroyAllSessions`: this keeps the caller's + * CURRENT cookie session alive so clicking the button doesn't log the user out + * of the device they pressed it on, and it does NOT revoke `ApiToken`s — those + * are long-lived programmatic credentials the user manages separately under + * /settings/api-tokens, not "sessions" in the device-list sense. It DOES revoke + * every native-client `RefreshToken` (each is a device login) so a signed-in + * phone/tablet is dropped too, matching the "everywhere" promise. + * + * Returns the number of OTHER web sessions removed so the surface can confirm. + */ +export async function destroyOtherSessions( + userId: string, + currentSessionId: string, +): Promise<{ sessionsRevoked: number }> { + const [deleted] = await prisma.$transaction([ + prisma.session.deleteMany({ + where: { userId, id: { not: currentSessionId } }, + }), + prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + ]); + return { sessionsRevoked: deleted.count }; +} + +/** + * v1.23 — revoke a single web session by id, scoped to the owning user so a + * caller can never delete another user's session row. Returns whether a row + * was actually removed (false → not found or not owned). + */ +export async function destroySessionById( + userId: string, + sessionId: string, +): Promise { + const result = await prisma.session.deleteMany({ + where: { id: sessionId, userId }, + }); + return result.count > 0; +} diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 28736b8af..959913b13 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -13,7 +13,7 @@ import { totpConfirmSchema, mfaDisableSchema, } from "@/lib/validations/mfa"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, stdResponses, errorEnvelope } from "./shared"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -129,6 +129,61 @@ const mfaToggleResponse = z .object({ enabled: z.boolean() }) .meta({ id: "MfaToggleResponse" }); +// ── v1.23 active sessions + security activity shapes ────────────────── + +const sessionListResponse = z + .object({ + sessions: z.array( + z.object({ + id: z.string(), + device: z + .string() + .describe("Coarse device label derived from the User-Agent."), + ipMasked: z + .string() + .nullable() + .describe( + "IP with the host portion masked — never the full address.", + ), + location: z + .string() + .nullable() + .describe("Resolved coarse location, when available."), + lastActiveAt: z.iso.datetime({ offset: true }).nullable(), + createdAt: z.iso.datetime({ offset: true }), + isCurrent: z + .boolean() + .describe("True for the session making this request."), + }), + ), + }) + .meta({ id: "SessionListResponse" }); + +const signOutEverywhereResponse = z + .object({ + sessionsRevoked: z + .number() + .int() + .describe("Number of OTHER sessions removed (the current one is kept)."), + }) + .meta({ id: "SignOutEverywhereResponse" }); + +const securityActivityResponse = z + .object({ + events: z.array( + z.object({ + action: z + .string() + .describe("Audit action name, e.g. auth.login.password."), + createdAt: z.iso.datetime({ offset: true }), + location: z.string().nullable(), + ipMasked: z.string().nullable().describe("Host-masked IP."), + carrier: z.string().nullable(), + }), + ), + }) + .meta({ id: "SecurityActivityResponse" }); + export const authPaths: NonNullable = { "/api/auth/login": { post: { @@ -320,4 +375,91 @@ export const authPaths: NonNullable = { }, }, }, + "/api/auth/me/sessions": { + get: { + tags: ["Auth"], + summary: "List active web sessions", + description: + "v1.23 — the user-facing active-session list (issue #64). One row per browser login with a coarse device label, masked IP, resolved location, sliding last-active time, and the current-session marker. Distinct from /api/auth/me/devices (notification devices).", + responses: { + "200": { + description: "Active sessions for the caller.", + content: { + "application/json": { + schema: dataEnvelope(sessionListResponse, "SessionListEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Auth"], + summary: "Sign out everywhere else", + description: + "v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current session. API tokens are not touched (manage those under /settings/api-tokens).", + responses: { + "200": { + description: "Other sessions revoked.", + content: { + "application/json": { + schema: dataEnvelope( + signOutEverywhereResponse, + "SignOutEverywhereEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/sessions/{id}": { + delete: { + tags: ["Auth"], + summary: "Revoke a single web session", + description: + "v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row).", + responses: { + "200": { + description: "Session revoked.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ revoked: z.boolean() }), + "SessionRevokeEnvelope", + ), + }, + }, + }, + "404": { + description: "Session not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/security-activity": { + get: { + tags: ["Auth"], + summary: "List recent account-security activity", + description: + "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced.", + responses: { + "200": { + description: "Recent security events for the caller.", + content: { + "application/json": { + schema: dataEnvelope( + securityActivityResponse, + "SecurityActivityEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/query-keys/auth.ts b/src/lib/query-keys/auth.ts index d02beb3fe..1e5836a0b 100644 --- a/src/lib/query-keys/auth.ts +++ b/src/lib/query-keys/auth.ts @@ -33,6 +33,21 @@ export const authKeys = { passkeys: () => ["passkeys"] as const, + /** + * v1.23 — the user-facing active-session list behind + * `GET /api/auth/me/sessions`. Revoking a single session or signing out + * everywhere invalidates this key so the surface re-reads the live set. + */ + sessions: () => ["auth", "sessions"] as const, + + /** + * v1.23 — the shared security-activity feed behind + * `GET /api/auth/me/security-activity` (logins, MFA, password change, + * session revoke, exports, deletions). Consumed by the account-security + * surface and the privacy dashboard. + */ + securityActivity: () => ["auth", "security-activity"] as const, + userAiProvider: () => ["user", "ai-provider"] as const, userProfile: () => ["user", "profile"] as const, /** diff --git a/tests/integration/sessions-and-known-devices.test.ts b/tests/integration/sessions-and-known-devices.test.ts new file mode 100644 index 000000000..2ea8d6743 --- /dev/null +++ b/tests/integration/sessions-and-known-devices.test.ts @@ -0,0 +1,131 @@ +/** + * Integration test — v1.23 active-session management + known-device ledger + * against a real Postgres (migration 0203 applied by the container harness). + * + * Covers the DB-layer contracts the unit mocks can't pin: + * - destroyOtherSessions keeps the current session, deletes the rest, revokes + * native refresh tokens, and leaves API tokens untouched. + * - destroySessionById is scoped to the owning user (no cross-user delete). + * - the (userId, deviceHash) unique index enforces the login-alert dedupe. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { destroyOtherSessions, destroySessionById } from "@/lib/auth/session"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +async function makeUser(username: string) { + return getPrismaClient().user.create({ + data: { username, email: `${username}@example.test` }, + }); +} + +describe("destroyOtherSessions", () => { + it("keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => { + const prisma = getPrismaClient(); + const user = await makeUser("sess-owner"); + + const current = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + const refresh = await prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: "rt-hash-1", + expiresAt: new Date(Date.now() + 1e6), + }, + }); + const apiToken = await prisma.apiToken.create({ + data: { + userId: user.id, + name: "automation", + tokenHash: "at-hash-1", + permissions: ["*"], + }, + }); + + const result = await destroyOtherSessions(user.id, current.id); + expect(result.sessionsRevoked).toBe(2); + + const remaining = await prisma.session.findMany({ + where: { userId: user.id }, + }); + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe(current.id); + + const rt = await prisma.refreshToken.findUnique({ + where: { id: refresh.id }, + }); + expect(rt?.revokedAt).not.toBeNull(); + + const at = await prisma.apiToken.findUnique({ where: { id: apiToken.id } }); + expect(at?.revoked).toBe(false); + }); +}); + +describe("destroySessionById", () => { + it("revokes an owned session and refuses a cross-user id", async () => { + const prisma = getPrismaClient(); + const owner = await makeUser("owner"); + const other = await makeUser("intruder"); + + const ownerSession = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + const otherSession = await prisma.session.create({ + data: { userId: other.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + + // Cross-user attempt: owner tries to delete the intruder's session. + expect(await destroySessionById(owner.id, otherSession.id)).toBe(false); + expect( + await prisma.session.findUnique({ where: { id: otherSession.id } }), + ).not.toBeNull(); + + // Owned delete succeeds. + expect(await destroySessionById(owner.id, ownerSession.id)).toBe(true); + expect( + await prisma.session.findUnique({ where: { id: ownerSession.id } }), + ).toBeNull(); + }); +}); + +describe("UserKnownDevice unique index", () => { + it("dedupes on (userId, deviceHash) — the same fingerprint cannot insert twice", async () => { + const prisma = getPrismaClient(); + const user = await makeUser("device-owner"); + + await prisma.userKnownDevice.create({ + data: { + userId: user.id, + deviceHash: "hash-abc", + label: "Firefox on macOS", + }, + }); + + await expect( + prisma.userKnownDevice.create({ + data: { userId: user.id, deviceHash: "hash-abc" }, + }), + ).rejects.toThrow(); + + // A different hash for the same user inserts fine. + await prisma.userKnownDevice.create({ + data: { userId: user.id, deviceHash: "hash-def" }, + }); + + const rows = await prisma.userKnownDevice.findMany({ + where: { userId: user.id }, + }); + expect(rows).toHaveLength(2); + }); +}); From de33f1ad4ceda179d37803d24b40ef59dd287a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:37:35 +0200 Subject: [PATCH 14/34] chore: remove the bug-report integration Drop the in-app bug-report surface end to end: the user submission page, the feedback intake and admin inbox, the optional GitHub-issue escalation, the operator config, and the dedicated achievement badges. - delete the /bugreport page and the bugreport / feedback / admin-feedback API routes - drop the Feedback model, its enums, and the AppSettings GitHub-issue and enable columns (migration 0205) - remove the admin Feedback section, the Bug Report integration card, and the system-status row - drop the bug-report nav entry and the app-settings provider that only carried its flag - remove the bug-hunter achievement badges and their metric - prune the related i18n keys across every locale and the encrypted-column and key-rotation registries --- docs/api/README.md | 4 +- messages/de.json | 89 +-- messages/en.json | 89 +-- messages/es.json | 89 +-- messages/fr.json | 89 +-- messages/it.json | 89 +-- messages/pl.json | 89 +-- .../0205_v123_drop_bugreport/migration.sql | 17 + prisma/schema.prisma | 54 -- scripts/rotate-encryption-key.ts | 2 - src/app/achievements/__tests__/page.test.tsx | 1 - src/app/admin/[section]/renderer.tsx | 10 - .../feedback/[id]/__tests__/route.test.ts | 93 ---- .../api/admin/feedback/[id]/github/route.ts | 96 ---- src/app/api/admin/feedback/[id]/route.ts | 87 --- src/app/api/admin/feedback/route.ts | 67 --- .../admin/settings/__tests__/route.test.ts | 60 +- src/app/api/admin/settings/route.ts | 35 -- .../settings/web-push-vapid/generate/route.ts | 3 - .../api/admin/status/__tests__/route.test.ts | 6 +- src/app/api/admin/status/route.ts | 5 - src/app/api/bugreport/__tests__/route.test.ts | 99 ---- src/app/api/bugreport/route.ts | 193 ------- src/app/api/bugreport/status/route.ts | 83 --- src/app/api/feedback/__tests__/route.test.ts | 155 ------ src/app/api/feedback/route.ts | 123 ----- .../__tests__/hidden-redaction.test.ts | 2 +- src/app/api/settings/account/route.ts | 11 +- src/app/bugreport/page.tsx | 247 --------- src/app/notifications/page.tsx | 2 +- src/app/privacy/page.tsx | 2 +- src/components/README.md | 2 +- .../__tests__/error-details.test.tsx | 32 +- .../admin/__tests__/sections.test.tsx | 12 +- src/components/admin/_shared.tsx | 55 +- src/components/admin/admin-shell.tsx | 6 - src/components/admin/bug-report-section.tsx | 142 ----- .../admin/feedback-inbox-section.tsx | 517 ------------------ .../admin/integrations-group-section.tsx | 9 +- src/components/admin/section-slugs.ts | 1 - .../admin/system-status-section.tsx | 9 - src/components/app-settings-provider.tsx | 81 --- src/components/error-details.tsx | 16 +- .../layout/__tests__/nav-model.test.ts | 34 +- .../layout/__tests__/sidebar-nav.test.tsx | 46 +- src/components/layout/auth-shell.tsx | 4 +- src/components/layout/bottom-nav.tsx | 9 +- src/components/layout/nav-model.ts | 51 +- src/components/layout/sidebar-nav.tsx | 8 +- src/components/providers.tsx | 15 +- src/lib/cache/__tests__/invalidate.test.ts | 13 - src/lib/cache/__tests__/server-cache.test.ts | 1 - src/lib/cache/invalidate.ts | 13 - src/lib/cache/server-cache.ts | 7 +- src/lib/crypto/encrypted-columns.ts | 1 - src/lib/db-compat.ts | 10 - src/lib/feedback/publish-github.ts | 174 ------ .../__tests__/achievements.test.ts | 9 +- src/lib/gamification/achievements-result.ts | 14 - src/lib/gamification/achievements.ts | 29 +- src/lib/query-keys/settings.ts | 2 - src/lib/validations/admin.ts | 14 - src/lib/validations/feedback.ts | 44 -- src/proxy.ts | 2 - tests/integration/admin-data-wipe.test.ts | 25 +- .../cache-invalidation-coverage.test.ts | 41 -- tests/integration/cascade-delete.test.ts | 19 +- tests/integration/setup.ts | 1 - 68 files changed, 115 insertions(+), 3344 deletions(-) create mode 100644 prisma/migrations/0205_v123_drop_bugreport/migration.sql delete mode 100644 src/app/api/admin/feedback/[id]/__tests__/route.test.ts delete mode 100644 src/app/api/admin/feedback/[id]/github/route.ts delete mode 100644 src/app/api/admin/feedback/[id]/route.ts delete mode 100644 src/app/api/admin/feedback/route.ts delete mode 100644 src/app/api/bugreport/__tests__/route.test.ts delete mode 100644 src/app/api/bugreport/route.ts delete mode 100644 src/app/api/bugreport/status/route.ts delete mode 100644 src/app/api/feedback/__tests__/route.test.ts delete mode 100644 src/app/api/feedback/route.ts delete mode 100644 src/app/bugreport/page.tsx delete mode 100644 src/components/admin/bug-report-section.tsx delete mode 100644 src/components/admin/feedback-inbox-section.tsx delete mode 100644 src/components/app-settings-provider.tsx delete mode 100644 src/lib/feedback/publish-github.ts delete mode 100644 src/lib/validations/feedback.ts diff --git a/docs/api/README.md b/docs/api/README.md index 0613cd95b..8578bb868 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -41,8 +41,8 @@ intentional: - **`tags`** — domain groupings (`Auth`, `Measurements`, `Medications`, `Mood`, `Dashboard`, `Insights`, `Achievements`, `DoctorReport`, `Integrations`, `User`, `Tokens`, `Notifications`, `Onboarding`, - `Health`, `Settings`, `Analytics`, `Export`, `AuditLog`, `Feedback`, - `BugReport`, `Monitoring`, `Webhooks`, `Ingest`, `Admin`). + `Health`, `Settings`, `Analytics`, `Export`, `AuditLog`, + `Monitoring`, `Webhooks`, `Ingest`, `Admin`). - **`securitySchemes`**: - `bearerAuth` — long-lived `hlk_*` API tokens issued via `POST /api/tokens`. Required on `/api/ingest/medication`. diff --git a/messages/de.json b/messages/de.json index 523158b31..ee4d5a1d6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -32,7 +32,6 @@ "copy": "Kopieren", "copyDetails": "Details kopieren", "retry": "Erneut versuchen", - "reportIssue": "Fehler melden", "showPassword": "Passwort anzeigen", "hidePassword": "Passwort verbergen", "learnMore": "Mehr erfahren" @@ -204,7 +203,6 @@ "insights": "Insights", "achievements": "Erfolge", "settings": "Einstellungen", - "bugreport": "Bugreport", "admin": "Admin-Konsole", "logout": "Abmelden", "login": "Anmelden", @@ -5064,7 +5062,6 @@ "webPushGlobal": "Browser-Push-Benachrichtigungen erlauben.", "moodLogGlobal": "moodLog-Erinnerungen erlauben.", "integrationWebPush": "Web Push", - "integrationBugReport": "Bug-Reports", "offlineGeoLabel": "Geodaten", "offlineGeoEnabled": "Offline (GeoLite2)", "offlineGeoFallback": "Fallback (ipwho.is) — MAXMIND_LICENSE_KEY in CI setzen", @@ -5101,12 +5098,6 @@ "configured": "Konfiguriert", "monitoringTestSuccess": "Testevent wurde gesendet", "monitoringTestFailed": "Monitoring-Test fehlgeschlagen", - "bugReportGithub": "Bugreport (GitHub Issues)", - "bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.", - "bugReportEnabledLabel": "Bug-Reports aktiviert", - "bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.", - "bugReportDeprecatedBadge": "Veraltet", - "bugReportDeprecatedNote": "Diese Funktion ist veraltet und wird in einer künftigen Version entfernt.", "assistant": { "title": "Assistenz-Oberflächen", "description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen. Der Hauptschalter erzwingt jede Unter-Option aus.", @@ -5140,10 +5131,6 @@ "description": "Schalte ein Modul für den gesamten Server ab. Ein hier deaktiviertes Modul ist für jedes Konto aus – unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten.", "saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden." }, - "bugReportRepo": "Repository", - "bugReportRepoPlaceholder": "owner/repo", - "bugReportToken": "Issue-Token", - "bugReportTokenPlaceholder": "ghp_...", "invites": { "title": "Einladungen", "description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung. Der Link wird nur einmal angezeigt.", @@ -5297,47 +5284,6 @@ "reminderCheckNotifOff": "Benachrichtigungen aus", "reminderCheckEventsToday": "Heutige Events", "reminderCheckNotifSent": "Gesendet", - "feedback": { - "tabOpen": "Offen", - "tabAcknowledged": "Bestätigt", - "tabResolved": "Erledigt", - "tabArchived": "Archiviert", - "subject": "Betreff", - "category": "Kategorie", - "user": "Benutzer", - "createdAt": "Eingegangen", - "actions": "Aktionen", - "details": "Details", - "emptyTitle": "Alles erledigt", - "emptyDescription": "Kein Nutzer-Feedback in diesem Status. Bug-Reports, Fragen und Feature-Wünsche erscheinen hier.", - "loading": "Lade Feedback...", - "anonymous": "anonym", - "categoryBug": "Bug", - "categoryFeature": "Feature", - "categoryQuestion": "Frage", - "categoryOther": "Sonstiges", - "metadataHeading": "Metadaten", - "metaUrl": "URL", - "metaLocale": "Sprache", - "metaUserAgent": "User-Agent", - "metaAppVersion": "App-Version", - "screenshotHeading": "Screenshot", - "adminNote": "Admin-Notiz", - "adminNotePlaceholder": "Interne Notiz (nicht für Benutzer sichtbar)", - "saveNote": "Notiz speichern", - "noteSaved": "Notiz gespeichert", - "actionAcknowledge": "Bestätigen", - "actionResolve": "Erledigen", - "actionArchive": "Archivieren", - "actionPublishGithub": "Auf GitHub veröffentlichen", - "publishedToGithub": "Auf GitHub veröffentlicht", - "viewIssue": "Issue ansehen", - "publishSuccess": "Issue erstellt", - "publishFailed": "GitHub-Veröffentlichung fehlgeschlagen", - "updateFailed": "Aktualisierung fehlgeschlagen", - "archiveConfirm": "Diesen Feedback-Eintrag archivieren?", - "submittedBy": "Eingereicht von" - }, "shell": { "overview": "Übersicht", "sectionsNav": "Admin-Bereiche" @@ -5390,10 +5336,6 @@ "title": "Coach", "subtitle": "Assistenz-Oberflächen, Server-Schlüssel sowie die Feedback-Qualitäts-Tabellen für Coach und Auswertungen an einem Ort." }, - "feedback": { - "title": "Feedback", - "subtitle": "Feedback, Bug-Reports und Feature-Wünsche der Nutzer sichten." - }, "reminders": { "title": "Erinnerungen", "subtitle": "Schwellwerte für Medikamentenerinnerungen anpassen." @@ -5583,10 +5525,6 @@ "title": "Oldschool", "description": "Melde dich mindestens einmal mit Username und Passwort an." }, - "bugReport1": { - "title": "Fehlerjäger", - "description": "Reiche einen Bugreport ein." - }, "loginStreak7": { "title": "Login-Serie 7T", "description": "Logge dich an 7 Tagen in Folge ein." @@ -5775,10 +5713,6 @@ "title": "Polyglott", "description": "Du hast die App-Sprache mindestens einmal gewechselt." }, - "hiddenBugBuddy": { - "title": "Bug-Kumpel", - "description": "Mindestens 5 Bugreports eingereicht — das Projekt liebt dich." - }, "missFree7": { "title": "Lückenlos · 7T", "description": "7 Tage in Folge ohne eine verpasste Dosis." @@ -5831,26 +5765,6 @@ "empty": "Noch keine Erfolge — trage weiter ein, um deinen ersten freizuschalten." } }, - "bugreport": { - "title": "Bugreport", - "subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.", - "loginRequired": "Bitte anmelden, um einen Bug zu melden.", - "bugTitle": "Titel", - "description": "Beschreibung", - "submit": "Senden", - "success": "Vielen Dank! Dein Bug Report wurde erfolgreich übermittelt.", - "errorCreating": "Fehler beim Erstellen", - "category": "Art", - "categoryBug": "Fehlermeldung", - "categoryFeature": "Feature-Wunsch", - "categoryQuestion": "Frage", - "categoryOther": "Sonstiges Feedback", - "bugTitlePlaceholder": "Kurze Zusammenfassung (optional)", - "githubEscalationNote": "Dein Feedback wird lokal gespeichert. Wenn der Administrator die GitHub-Integration aktiviert, kann es auf Wunsch als öffentliches Issue veröffentlicht werden.", - "internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar.", - "disabledTitle": "Bug-Reports sind deaktiviert", - "disabledBody": "Der Administrator hat Bug-Reports auf diesem Server deaktiviert." - }, "notifications": { "title": "Benachrichtigungen", "eventColumn": "Ereignis", @@ -5955,7 +5869,8 @@ "eventMeasurementReminder": "Mess-Erinnerung", "eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.", "eventMedicationIntakeSync": "Einnahme-Synchronisierung", - "eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird." + "eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.", + "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten." }, "i18n": { "maintainershipBanner": { diff --git a/messages/en.json b/messages/en.json index 16ec52b02..3bc79e698 100644 --- a/messages/en.json +++ b/messages/en.json @@ -32,7 +32,6 @@ "copy": "Copy", "copyDetails": "Copy details", "retry": "Retry", - "reportIssue": "Report issue", "showPassword": "Show password", "hidePassword": "Hide password", "learnMore": "Learn more" @@ -204,7 +203,6 @@ "insights": "Insights", "achievements": "Achievements", "settings": "Settings", - "bugreport": "Bug Report", "admin": "Admin Console", "logout": "Sign out", "login": "Sign in", @@ -5064,7 +5062,6 @@ "webPushGlobal": "Allow browser push notifications.", "moodLogGlobal": "Allow mood-log reminders.", "integrationWebPush": "Web Push", - "integrationBugReport": "Bug Report", "offlineGeoLabel": "Geo data", "offlineGeoEnabled": "Offline (GeoLite2)", "offlineGeoFallback": "Fallback (ipwho.is) — set MAXMIND_LICENSE_KEY in CI", @@ -5101,12 +5098,6 @@ "configured": "Configured", "monitoringTestSuccess": "Test event sent", "monitoringTestFailed": "Monitoring test failed", - "bugReportGithub": "Bug report (GitHub Issues)", - "bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.", - "bugReportEnabledLabel": "Bug reports enabled", - "bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.", - "bugReportDeprecatedBadge": "Deprecated", - "bugReportDeprecatedNote": "This feature is deprecated and will be removed in a future version.", "assistant": { "title": "Assistant Surfaces", "description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration. The master kills every sub-flag at once.", @@ -5140,10 +5131,6 @@ "description": "Turn a module off for the whole server. A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled.", "saveError": "Could not update module availability." }, - "bugReportRepo": "Repository", - "bugReportRepoPlaceholder": "owner/repo", - "bugReportToken": "Issue token", - "bugReportTokenPlaceholder": "ghp_...", "invites": { "title": "Invites", "description": "Registration invite links — they work even while registration is closed. The link is shown only once.", @@ -5297,47 +5284,6 @@ "reminderCheckNotifOff": "Notifications off", "reminderCheckEventsToday": "Events today", "reminderCheckNotifSent": "Sent", - "feedback": { - "tabOpen": "Open", - "tabAcknowledged": "Acknowledged", - "tabResolved": "Resolved", - "tabArchived": "Archived", - "subject": "Subject", - "category": "Category", - "user": "User", - "createdAt": "Submitted", - "actions": "Actions", - "details": "Details", - "emptyTitle": "All caught up", - "emptyDescription": "No user feedback in this status. Submitted bugs, questions, and feature requests will appear here.", - "loading": "Loading feedback...", - "anonymous": "anonymous", - "categoryBug": "Bug", - "categoryFeature": "Feature", - "categoryQuestion": "Question", - "categoryOther": "Other", - "metadataHeading": "Metadata", - "metaUrl": "URL", - "metaLocale": "Locale", - "metaUserAgent": "User agent", - "metaAppVersion": "App version", - "screenshotHeading": "Screenshot", - "adminNote": "Admin note", - "adminNotePlaceholder": "Internal note (not visible to user)", - "saveNote": "Save note", - "noteSaved": "Note saved", - "actionAcknowledge": "Acknowledge", - "actionResolve": "Resolve", - "actionArchive": "Archive", - "actionPublishGithub": "Publish to GitHub", - "publishedToGithub": "Published to GitHub", - "viewIssue": "View issue", - "publishSuccess": "Issue created", - "publishFailed": "GitHub publish failed", - "updateFailed": "Update failed", - "archiveConfirm": "Archive this feedback entry?", - "submittedBy": "Submitted by" - }, "shell": { "overview": "Overview", "sectionsNav": "Admin sections" @@ -5390,10 +5336,6 @@ "title": "Coach", "subtitle": "Assistant surfaces, server key, and the Coach and Insights feedback-quality tables in one place." }, - "feedback": { - "title": "Feedback", - "subtitle": "Triage user feedback, bug reports, and feature requests." - }, "reminders": { "title": "Reminders", "subtitle": "Tune medication reminder thresholds and run a manual check." @@ -5583,10 +5525,6 @@ "title": "Old school", "description": "Sign in at least once with username and password." }, - "bugReport1": { - "title": "Bug hunter", - "description": "Submit a bug report." - }, "loginStreak7": { "title": "Login streak 7d", "description": "Sign in on 7 consecutive days." @@ -5775,10 +5713,6 @@ "title": "Polyglot", "description": "Switched the app language at least once." }, - "hiddenBugBuddy": { - "title": "Bug buddy", - "description": "Submitted at least 5 bug reports — the project loves you." - }, "missFree7": { "title": "No dose missed · 7d", "description": "7 consecutive days without a missed dose." @@ -5831,26 +5765,6 @@ "empty": "No achievements yet — keep logging to unlock your first." } }, - "bugreport": { - "title": "Bug Report", - "subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.", - "loginRequired": "Please sign in to report a bug.", - "bugTitle": "Title", - "description": "Description", - "submit": "Send", - "success": "Thank you! Your bug report has been submitted successfully.", - "errorCreating": "Error creating report", - "category": "Type", - "categoryBug": "Bug report", - "categoryFeature": "Feature request", - "categoryQuestion": "Question", - "categoryOther": "Other feedback", - "bugTitlePlaceholder": "Short summary (optional)", - "githubEscalationNote": "Your feedback is stored locally. If the admin enables GitHub integration, it can be promoted to a public issue on request.", - "internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server.", - "disabledTitle": "Bug reports are turned off", - "disabledBody": "The administrator has disabled bug reports on this server." - }, "notifications": { "title": "Notifications", "eventColumn": "Event", @@ -5955,7 +5869,8 @@ "eventMeasurementReminder": "Measurement reminder", "eventMeasurementReminderDesc": "Sent when a tracked measurement is due.", "eventMedicationIntakeSync": "Medication intake sync", - "eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped." + "eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.", + "loginRequired": "Please sign in to manage notifications." }, "i18n": { "maintainershipBanner": { diff --git a/messages/es.json b/messages/es.json index 8bea74277..9aff55383 100644 --- a/messages/es.json +++ b/messages/es.json @@ -32,7 +32,6 @@ "copy": "Copiar", "copyDetails": "Copiar detalles", "retry": "Reintentar", - "reportIssue": "Reportar problema", "showPassword": "Mostrar contraseña", "hidePassword": "Ocultar contraseña", "learnMore": "Más información" @@ -204,7 +203,6 @@ "insights": "Insights", "achievements": "Logros", "settings": "Ajustes", - "bugreport": "Reporte de bug", "admin": "Consola de administración", "logout": "Cerrar sesión", "login": "Iniciar sesión", @@ -5064,7 +5062,6 @@ "webPushGlobal": "Permitir notificaciones push del navegador.", "moodLogGlobal": "Permitir recordatorios del registro de ánimo.", "integrationWebPush": "Web Push", - "integrationBugReport": "Informe de errores", "offlineGeoLabel": "Datos geográficos", "offlineGeoEnabled": "Sin conexión (GeoLite2)", "offlineGeoFallback": "Respaldo (ipwho.is) — configura MAXMIND_LICENSE_KEY en CI", @@ -5101,12 +5098,6 @@ "configured": "Configurado", "monitoringTestSuccess": "Evento de prueba enviado", "monitoringTestFailed": "La prueba de monitorización falló", - "bugReportGithub": "Informe de errores (GitHub Issues)", - "bugReportGithubDescription": "Configura el token de issues de GitHub y el repositorio para los informes de errores entrantes.", - "bugReportEnabledLabel": "Informes de errores activados", - "bugReportEnabledDescription": "Cuando está desactivado, el botón de informe en la app se oculta; el token y el repositorio permanecen configurados.", - "bugReportDeprecatedBadge": "Obsoleto", - "bugReportDeprecatedNote": "Esta función está obsoleta y se eliminará en una versión futura.", "assistant": { "title": "Superficies del asistente", "description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor. El interruptor maestro fuerza cada opción secundaria a off.", @@ -5140,10 +5131,6 @@ "description": "Desactiva un módulo para todo el servidor. Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar.", "saveError": "No se pudo actualizar la disponibilidad de módulos." }, - "bugReportRepo": "Repositorio", - "bugReportRepoPlaceholder": "owner/repo", - "bugReportToken": "Token de issues", - "bugReportTokenPlaceholder": "ghp_...", "invites": { "title": "Invitaciones", "description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado. El enlace se muestra solo una vez.", @@ -5297,47 +5284,6 @@ "reminderCheckNotifOff": "Notificaciones desactivadas", "reminderCheckEventsToday": "Eventos de hoy", "reminderCheckNotifSent": "Enviadas", - "feedback": { - "tabOpen": "Abierto", - "tabAcknowledged": "Confirmado", - "tabResolved": "Resuelto", - "tabArchived": "Archivado", - "subject": "Asunto", - "category": "Categoría", - "user": "Usuario", - "createdAt": "Enviado", - "actions": "Acciones", - "details": "Detalles", - "emptyTitle": "Todo al día", - "emptyDescription": "No hay comentarios de usuarios en este estado. Los errores, preguntas y solicitudes de funciones enviados aparecerán aquí.", - "loading": "Cargando comentarios...", - "anonymous": "anónimo", - "categoryBug": "Error", - "categoryFeature": "Función", - "categoryQuestion": "Pregunta", - "categoryOther": "Otro", - "metadataHeading": "Metadatos", - "metaUrl": "URL", - "metaLocale": "Idioma", - "metaUserAgent": "Agente de usuario", - "metaAppVersion": "Versión de la app", - "screenshotHeading": "Screenshot", - "adminNote": "Nota de administración", - "adminNotePlaceholder": "Nota interna (no visible para el usuario)", - "saveNote": "Guardar nota", - "noteSaved": "Nota guardada", - "actionAcknowledge": "Confirmar", - "actionResolve": "Resolver", - "actionArchive": "Archivar", - "actionPublishGithub": "Publicar en GitHub", - "publishedToGithub": "Publicado en GitHub", - "viewIssue": "Ver issue", - "publishSuccess": "Issue creada", - "publishFailed": "La publicación en GitHub falló", - "updateFailed": "La actualización falló", - "archiveConfirm": "¿Archivar esta entrada de comentarios?", - "submittedBy": "Enviado por" - }, "shell": { "overview": "Resumen", "sectionsNav": "Secciones de administración" @@ -5390,10 +5336,6 @@ "title": "Coach", "subtitle": "Superficies del asistente, clave del servidor y las tablas de calidad de comentarios del Coach y de los análisis, en un solo lugar." }, - "feedback": { - "title": "Comentarios", - "subtitle": "Clasifica los comentarios de usuarios, los informes de errores y las solicitudes de funciones." - }, "reminders": { "title": "Recordatorios", "subtitle": "Ajusta los umbrales de los recordatorios de medicación y ejecuta una comprobación manual." @@ -5583,10 +5525,6 @@ "title": "A la antigua", "description": "Inicia sesión al menos una vez con usuario y contraseña." }, - "bugReport1": { - "title": "Cazador de errores", - "description": "Envía un informe de error." - }, "loginStreak7": { "title": "Racha de inicios 7d", "description": "Inicia sesión 7 días seguidos." @@ -5775,10 +5713,6 @@ "title": "Políglota", "description": "Cambiaste el idioma de la app al menos una vez." }, - "hiddenBugBuddy": { - "title": "Colega de errores", - "description": "Enviaste al menos 5 informes de error — el proyecto te quiere." - }, "missFree7": { "title": "Sin dosis perdidas · 7d", "description": "7 días consecutivos sin una dosis perdida." @@ -5831,26 +5765,6 @@ "empty": "Aún no hay logros — sigue registrando para desbloquear el primero." } }, - "bugreport": { - "title": "Reporte de bug", - "subtitle": "Envía un fallo, una sugerencia o una pregunta. El administrador del servidor verá tu envío.", - "loginRequired": "Inicia sesión para reportar un bug.", - "bugTitle": "Título", - "description": "Descripción", - "submit": "Enviar", - "success": "¡Gracias! Tu reporte se ha enviado correctamente.", - "errorCreating": "Error al crear", - "category": "Tipo", - "categoryBug": "Fallo", - "categoryFeature": "Sugerencia", - "categoryQuestion": "Pregunta", - "categoryOther": "Otro feedback", - "bugTitlePlaceholder": "Resumen breve (opcional)", - "githubEscalationNote": "Tu feedback se guarda localmente. Si el administrador activa la integración con GitHub, puede publicarse como issue público a petición.", - "internalOnlyNote": "Tu feedback se guarda localmente y solo es visible para el administrador de este servidor.", - "disabledTitle": "Los informes de errores están desactivados", - "disabledBody": "El administrador ha desactivado los informes de errores en este servidor." - }, "notifications": { "title": "Notificaciones", "eventColumn": "Evento", @@ -5955,7 +5869,8 @@ "eventMeasurementReminder": "Recordatorio de medición", "eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.", "eventMedicationIntakeSync": "Sincronización de tomas", - "eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis." + "eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.", + "loginRequired": "Inicia sesión para gestionar las notificaciones." }, "i18n": { "maintainershipBanner": { diff --git a/messages/fr.json b/messages/fr.json index d943773ff..8d799c471 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -32,7 +32,6 @@ "copy": "Copier", "copyDetails": "Copier les détails", "retry": "Réessayer", - "reportIssue": "Signaler un problème", "showPassword": "Afficher le mot de passe", "hidePassword": "Masquer le mot de passe", "learnMore": "En savoir plus" @@ -204,7 +203,6 @@ "insights": "Insights", "achievements": "Succès", "settings": "Paramètres", - "bugreport": "Rapport de bug", "admin": "Console d’administration", "logout": "Se déconnecter", "login": "Se connecter", @@ -5064,7 +5062,6 @@ "webPushGlobal": "Autoriser les notifications push du navigateur.", "moodLogGlobal": "Autoriser les rappels du journal d'humeur.", "integrationWebPush": "Web Push", - "integrationBugReport": "Rapport de bug", "offlineGeoLabel": "Données géographiques", "offlineGeoEnabled": "Hors ligne (GeoLite2)", "offlineGeoFallback": "Fallback (ipwho.is) — définissez MAXMIND_LICENSE_KEY dans la CI", @@ -5101,12 +5098,6 @@ "configured": "Configuré", "monitoringTestSuccess": "Événement de test envoyé", "monitoringTestFailed": "Échec du test de surveillance", - "bugReportGithub": "Rapport de bug (GitHub Issues)", - "bugReportGithubDescription": "Configurez le jeton d'issues GitHub et le dépôt pour les rapports de bug entrants.", - "bugReportEnabledLabel": "Rapports de bug activés", - "bugReportEnabledDescription": "Lorsque cette option est désactivée, le bouton de signalement dans l'app est masqué — le jeton et le dépôt restent configurés.", - "bugReportDeprecatedBadge": "Obsolète", - "bugReportDeprecatedNote": "Cette fonctionnalité est obsolète et sera supprimée dans une version future.", "assistant": { "title": "Surfaces d'assistance", "description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur. Le commutateur principal force chaque option secondaire à off.", @@ -5140,10 +5131,6 @@ "description": "Désactivez un module pour tout le serveur. Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés.", "saveError": "Impossible de mettre à jour la disponibilité des modules." }, - "bugReportRepo": "Dépôt", - "bugReportRepoPlaceholder": "owner/repo", - "bugReportToken": "Jeton d'issue", - "bugReportTokenPlaceholder": "ghp_...", "invites": { "title": "Invitations", "description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées. Le lien n'est affiché qu'une seule fois.", @@ -5297,47 +5284,6 @@ "reminderCheckNotifOff": "Notifications désactivées", "reminderCheckEventsToday": "Événements aujourd'hui", "reminderCheckNotifSent": "Envoyées", - "feedback": { - "tabOpen": "Ouvert", - "tabAcknowledged": "Pris en compte", - "tabResolved": "Résolu", - "tabArchived": "Archivé", - "subject": "Sujet", - "category": "Catégorie", - "user": "Utilisateur", - "createdAt": "Soumis", - "actions": "Actions", - "details": "Détails", - "emptyTitle": "Tout est à jour", - "emptyDescription": "Aucun retour utilisateur dans ce statut. Les bugs, questions et demandes de fonctionnalités soumis apparaîtront ici.", - "loading": "Chargement des retours...", - "anonymous": "anonyme", - "categoryBug": "Bug", - "categoryFeature": "Fonctionnalité", - "categoryQuestion": "Question", - "categoryOther": "Autre", - "metadataHeading": "Métadonnées", - "metaUrl": "URL", - "metaLocale": "Langue", - "metaUserAgent": "Agent utilisateur", - "metaAppVersion": "Version de l'app", - "screenshotHeading": "Screenshot", - "adminNote": "Note d'administration", - "adminNotePlaceholder": "Note interne (non visible par l'utilisateur)", - "saveNote": "Enregistrer la note", - "noteSaved": "Note enregistrée", - "actionAcknowledge": "Prendre en compte", - "actionResolve": "Résoudre", - "actionArchive": "Archiver", - "actionPublishGithub": "Publier sur GitHub", - "publishedToGithub": "Publié sur GitHub", - "viewIssue": "Voir l'issue", - "publishSuccess": "Issue créée", - "publishFailed": "Échec de la publication sur GitHub", - "updateFailed": "Échec de la mise à jour", - "archiveConfirm": "Archiver cette entrée de retour ?", - "submittedBy": "Soumis par" - }, "shell": { "overview": "Vue d'ensemble", "sectionsNav": "Sections d'administration" @@ -5390,10 +5336,6 @@ "title": "Coach", "subtitle": "Surfaces d'assistance, clé du serveur et les tableaux de qualité des retours du Coach et des analyses, au même endroit." }, - "feedback": { - "title": "Retours", - "subtitle": "Triez les retours utilisateurs, les rapports de bug et les demandes de fonctionnalités." - }, "reminders": { "title": "Rappels", "subtitle": "Réglez les seuils des rappels de médicaments et lancez une vérification manuelle." @@ -5583,10 +5525,6 @@ "title": "À l’ancienne", "description": "Connectez-vous au moins une fois avec un identifiant et un mot de passe." }, - "bugReport1": { - "title": "Chasseur de bugs", - "description": "Soumettez un rapport de bug." - }, "loginStreak7": { "title": "Série de connexions 7j", "description": "Connectez-vous 7 jours d’affilée." @@ -5775,10 +5713,6 @@ "title": "Polyglotte", "description": "Vous avez changé la langue de l’application au moins une fois." }, - "hiddenBugBuddy": { - "title": "Compagnon des bugs", - "description": "Vous avez soumis au moins 5 rapports de bug — le projet vous adore." - }, "missFree7": { "title": "Aucune dose oubliée · 7j", "description": "7 jours consécutifs sans dose oubliée." @@ -5831,26 +5765,6 @@ "empty": "Pas encore de réussites — continuez à suivre pour débloquer la première." } }, - "bugreport": { - "title": "Rapport de bug", - "subtitle": "Envoie un bug, une suggestion ou une question. L’administrateur du serveur verra ton envoi.", - "loginRequired": "Connecte-toi pour signaler un bug.", - "bugTitle": "Titre", - "description": "Description", - "submit": "Envoyer", - "success": "Merci ! Ton rapport a bien été envoyé.", - "errorCreating": "Erreur lors de la création", - "category": "Type", - "categoryBug": "Bug", - "categoryFeature": "Suggestion", - "categoryQuestion": "Question", - "categoryOther": "Autre retour", - "bugTitlePlaceholder": "Résumé court (optionnel)", - "githubEscalationNote": "Ton retour est stocké localement. Si l’administrateur active l’intégration GitHub, il pourra être publié comme issue public à la demande.", - "internalOnlyNote": "Ton retour est stocké localement et n’est visible que par l’administrateur de ce serveur.", - "disabledTitle": "Les rapports de bug sont désactivés", - "disabledBody": "L'administrateur a désactivé les rapports de bug sur ce serveur." - }, "notifications": { "title": "Notifications", "eventColumn": "Événement", @@ -5955,7 +5869,8 @@ "eventMeasurementReminder": "Rappel de mesure", "eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.", "eventMedicationIntakeSync": "Synchronisation des prises", - "eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée." + "eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.", + "loginRequired": "Connecte-toi pour gérer les notifications." }, "i18n": { "maintainershipBanner": { diff --git a/messages/it.json b/messages/it.json index 38c204fbb..c166e09cf 100644 --- a/messages/it.json +++ b/messages/it.json @@ -32,7 +32,6 @@ "copy": "Copia", "copyDetails": "Copia dettagli", "retry": "Riprova", - "reportIssue": "Segnala un problema", "showPassword": "Mostra password", "hidePassword": "Nascondi password", "learnMore": "Scopri di più" @@ -204,7 +203,6 @@ "insights": "Insights", "achievements": "Traguardi", "settings": "Impostazioni", - "bugreport": "Segnalazione bug", "admin": "Console di amministrazione", "logout": "Esci", "login": "Accedi", @@ -5064,7 +5062,6 @@ "webPushGlobal": "Consentire le notifiche push del browser.", "moodLogGlobal": "Consentire i promemoria del diario dell'umore.", "integrationWebPush": "Web Push", - "integrationBugReport": "Segnalazione di bug", "offlineGeoLabel": "Dati geografici", "offlineGeoEnabled": "Offline (GeoLite2)", "offlineGeoFallback": "Fallback (ipwho.is) — imposta MAXMIND_LICENSE_KEY in CI", @@ -5101,12 +5098,6 @@ "configured": "Configurato", "monitoringTestSuccess": "Evento di prova inviato", "monitoringTestFailed": "Test di monitoraggio non riuscito", - "bugReportGithub": "Segnalazione di bug (GitHub Issues)", - "bugReportGithubDescription": "Configura il token delle issue di GitHub e il repository per le segnalazioni di bug in arrivo.", - "bugReportEnabledLabel": "Segnalazioni di bug attive", - "bugReportEnabledDescription": "Se disattivato, il pulsante di segnalazione nell'app viene nascosto: token e repository restano configurati.", - "bugReportDeprecatedBadge": "Obsoleto", - "bugReportDeprecatedNote": "Questa funzione è obsoleta e verrà rimossa in una versione futura.", "assistant": { "title": "Superfici dell'assistente", "description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider. Il master forza ogni opzione secondaria su off.", @@ -5140,10 +5131,6 @@ "description": "Disattiva un modulo per l'intero server. Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati.", "saveError": "Impossibile aggiornare la disponibilità dei moduli." }, - "bugReportRepo": "Repository", - "bugReportRepoPlaceholder": "owner/repo", - "bugReportToken": "Token delle issue", - "bugReportTokenPlaceholder": "ghp_...", "invites": { "title": "Inviti", "description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa. Il link viene mostrato una sola volta.", @@ -5297,47 +5284,6 @@ "reminderCheckNotifOff": "Notifiche disattivate", "reminderCheckEventsToday": "Eventi di oggi", "reminderCheckNotifSent": "Inviate", - "feedback": { - "tabOpen": "Aperto", - "tabAcknowledged": "Preso in carico", - "tabResolved": "Risolto", - "tabArchived": "Archiviato", - "subject": "Oggetto", - "category": "Categoria", - "user": "Utente", - "createdAt": "Inviato", - "actions": "Azioni", - "details": "Dettagli", - "emptyTitle": "Tutto in ordine", - "emptyDescription": "Nessun feedback degli utenti in questo stato. Bug, domande e richieste di funzionalità inviati compariranno qui.", - "loading": "Caricamento feedback...", - "anonymous": "anonimo", - "categoryBug": "Bug", - "categoryFeature": "Funzionalità", - "categoryQuestion": "Domanda", - "categoryOther": "Altro", - "metadataHeading": "Metadati", - "metaUrl": "URL", - "metaLocale": "Lingua", - "metaUserAgent": "User agent", - "metaAppVersion": "Versione dell'app", - "screenshotHeading": "Screenshot", - "adminNote": "Nota amministrativa", - "adminNotePlaceholder": "Nota interna (non visibile all'utente)", - "saveNote": "Salva nota", - "noteSaved": "Nota salvata", - "actionAcknowledge": "Prendi in carico", - "actionResolve": "Risolvi", - "actionArchive": "Archivia", - "actionPublishGithub": "Pubblica su GitHub", - "publishedToGithub": "Pubblicato su GitHub", - "viewIssue": "Vedi issue", - "publishSuccess": "Issue creata", - "publishFailed": "Pubblicazione su GitHub non riuscita", - "updateFailed": "Aggiornamento non riuscito", - "archiveConfirm": "Archiviare questa voce di feedback?", - "submittedBy": "Inviato da" - }, "shell": { "overview": "Panoramica", "sectionsNav": "Sezioni di amministrazione" @@ -5390,10 +5336,6 @@ "title": "Coach", "subtitle": "Superfici dell'assistente, chiave del server e le tabelle di qualità del feedback di Coach e analisi, in un unico posto." }, - "feedback": { - "title": "Feedback", - "subtitle": "Smista i feedback degli utenti, le segnalazioni di bug e le richieste di funzionalità." - }, "reminders": { "title": "Promemoria", "subtitle": "Regola le soglie dei promemoria dei farmaci ed esegui un controllo manuale." @@ -5583,10 +5525,6 @@ "title": "Vecchia scuola", "description": "Accedi almeno una volta con nome utente e password." }, - "bugReport1": { - "title": "Cacciatore di bug", - "description": "Invia una segnalazione di bug." - }, "loginStreak7": { "title": "Serie di accessi 7g", "description": "Accedi per 7 giorni consecutivi." @@ -5775,10 +5713,6 @@ "title": "Poliglotta", "description": "Hai cambiato la lingua dell’app almeno una volta." }, - "hiddenBugBuddy": { - "title": "Amico dei bug", - "description": "Hai inviato almeno 5 segnalazioni di bug — il progetto ti ama." - }, "missFree7": { "title": "Nessuna dose persa · 7g", "description": "7 giorni consecutivi senza una dose persa." @@ -5831,26 +5765,6 @@ "empty": "Ancora nessun obiettivo — continua a registrare per sbloccare il primo." } }, - "bugreport": { - "title": "Segnalazione bug", - "subtitle": "Invia un bug, un’idea o una domanda. L’amministratore del server vedrà la tua segnalazione.", - "loginRequired": "Accedi per segnalare un bug.", - "bugTitle": "Titolo", - "description": "Descrizione", - "submit": "Invia", - "success": "Grazie! La tua segnalazione è stata inviata.", - "errorCreating": "Errore durante la creazione", - "category": "Tipo", - "categoryBug": "Bug", - "categoryFeature": "Suggerimento", - "categoryQuestion": "Domanda", - "categoryOther": "Altro feedback", - "bugTitlePlaceholder": "Riassunto breve (opzionale)", - "githubEscalationNote": "Il feedback viene salvato localmente. Se l’amministratore attiva l’integrazione GitHub, può essere pubblicato come issue pubblico su richiesta.", - "internalOnlyNote": "Il feedback viene salvato localmente ed è visibile solo all’amministratore di questo server.", - "disabledTitle": "Le segnalazioni di bug sono disattivate", - "disabledBody": "L'amministratore ha disattivato le segnalazioni di bug su questo server." - }, "notifications": { "title": "Notifiche", "eventColumn": "Evento", @@ -5955,7 +5869,8 @@ "eventMeasurementReminder": "Promemoria misurazione", "eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.", "eventMedicationIntakeSync": "Sincronizzazione assunzioni", - "eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata." + "eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.", + "loginRequired": "Accedi per gestire le notifiche." }, "i18n": { "maintainershipBanner": { diff --git a/messages/pl.json b/messages/pl.json index 566625aa1..ad71a49b0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -32,7 +32,6 @@ "copy": "Kopiuj", "copyDetails": "Kopiuj szczegóły", "retry": "Spróbuj ponownie", - "reportIssue": "Zgłoś problem", "showPassword": "Pokaż hasło", "hidePassword": "Ukryj hasło", "learnMore": "Dowiedz się więcej" @@ -204,7 +203,6 @@ "insights": "Insights", "achievements": "Osiągnięcia", "settings": "Ustawienia", - "bugreport": "Zgłoszenie błędu", "admin": "Konsola administratora", "logout": "Wyloguj", "login": "Zaloguj", @@ -5064,7 +5062,6 @@ "webPushGlobal": "Zezwól na powiadomienia push przeglądarki.", "moodLogGlobal": "Zezwól na przypomnienia dziennika nastroju.", "integrationWebPush": "Web Push", - "integrationBugReport": "Zgłoszenie błędu", "offlineGeoLabel": "Dane geograficzne", "offlineGeoEnabled": "Offline (GeoLite2)", "offlineGeoFallback": "Awaryjne (ipwho.is) — ustaw MAXMIND_LICENSE_KEY w CI", @@ -5101,12 +5098,6 @@ "configured": "Skonfigurowano", "monitoringTestSuccess": "Wysłano zdarzenie testowe", "monitoringTestFailed": "Test monitorowania nie powiódł się", - "bugReportGithub": "Zgłoszenie błędu (GitHub Issues)", - "bugReportGithubDescription": "Skonfiguruj token zgłoszeń GitHub i repozytorium dla przychodzących zgłoszeń błędów.", - "bugReportEnabledLabel": "Zgłaszanie błędów włączone", - "bugReportEnabledDescription": "Gdy wyłączone, przycisk zgłaszania w aplikacji jest ukryty — token i repozytorium pozostają skonfigurowane.", - "bugReportDeprecatedBadge": "Wycofane", - "bugReportDeprecatedNote": "Ta funkcja jest wycofywana i zostanie usunięta w przyszłej wersji.", "assistant": { "title": "Powierzchnie asystenta", "description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy. Główny wyłącznik wymusza wyłączenie każdej opcji podrzędnej.", @@ -5140,10 +5131,6 @@ "description": "Wyłącz moduł dla całego serwera. Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć.", "saveError": "Nie udało się zaktualizować dostępności modułów." }, - "bugReportRepo": "Repozytorium", - "bugReportRepoPlaceholder": "owner/repo", - "bugReportToken": "Token zgłoszeń", - "bugReportTokenPlaceholder": "ghp_...", "invites": { "title": "Zaproszenia", "description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji. Link jest wyświetlany tylko raz.", @@ -5297,47 +5284,6 @@ "reminderCheckNotifOff": "Powiadomienia wyłączone", "reminderCheckEventsToday": "Zdarzenia dzisiaj", "reminderCheckNotifSent": "Wysłane", - "feedback": { - "tabOpen": "Otwarte", - "tabAcknowledged": "Potwierdzone", - "tabResolved": "Rozwiązane", - "tabArchived": "Zarchiwizowane", - "subject": "Temat", - "category": "Kategoria", - "user": "Użytkownik", - "createdAt": "Przesłano", - "actions": "Akcje", - "details": "Szczegóły", - "emptyTitle": "Wszystko na bieżąco", - "emptyDescription": "Brak opinii użytkowników w tym statusie. Przesłane błędy, pytania i prośby o funkcje pojawią się tutaj.", - "loading": "Ładowanie opinii...", - "anonymous": "anonimowy", - "categoryBug": "Błąd", - "categoryFeature": "Funkcja", - "categoryQuestion": "Pytanie", - "categoryOther": "Inne", - "metadataHeading": "Metadane", - "metaUrl": "URL", - "metaLocale": "Język", - "metaUserAgent": "Agent użytkownika", - "metaAppVersion": "Wersja aplikacji", - "screenshotHeading": "Screenshot", - "adminNote": "Notatka administratora", - "adminNotePlaceholder": "Notatka wewnętrzna (niewidoczna dla użytkownika)", - "saveNote": "Zapisz notatkę", - "noteSaved": "Notatka zapisana", - "actionAcknowledge": "Potwierdź", - "actionResolve": "Rozwiąż", - "actionArchive": "Archiwizuj", - "actionPublishGithub": "Opublikuj na GitHub", - "publishedToGithub": "Opublikowano na GitHub", - "viewIssue": "Zobacz zgłoszenie", - "publishSuccess": "Utworzono zgłoszenie", - "publishFailed": "Publikacja na GitHub nie powiodła się", - "updateFailed": "Aktualizacja nie powiodła się", - "archiveConfirm": "Zarchiwizować ten wpis opinii?", - "submittedBy": "Przesłane przez" - }, "shell": { "overview": "Przegląd", "sectionsNav": "Sekcje administracyjne" @@ -5390,10 +5336,6 @@ "title": "Coach", "subtitle": "Powierzchnie asystenta, klucz serwera oraz tabele jakości opinii Coacha i analiz w jednym miejscu." }, - "feedback": { - "title": "Opinie", - "subtitle": "Segreguj opinie użytkowników, zgłoszenia błędów i prośby o funkcje." - }, "reminders": { "title": "Przypomnienia", "subtitle": "Dostrój progi przypomnień o lekach i uruchom ręczne sprawdzenie." @@ -5583,10 +5525,6 @@ "title": "Stara szkoła", "description": "Zaloguj się co najmniej raz nazwą użytkownika i hasłem." }, - "bugReport1": { - "title": "Łowca błędów", - "description": "Wyślij zgłoszenie błędu." - }, "loginStreak7": { "title": "Seria logowań 7d", "description": "Zaloguj się przez 7 kolejnych dni." @@ -5775,10 +5713,6 @@ "title": "Poliglota", "description": "Zmieniłeś język aplikacji co najmniej raz." }, - "hiddenBugBuddy": { - "title": "Towarzysz błędów", - "description": "Wysłałeś co najmniej 5 zgłoszeń błędów — projekt Cię kocha." - }, "missFree7": { "title": "Bez pominiętej dawki · 7 dni", "description": "7 kolejnych dni bez pominiętej dawki." @@ -5831,26 +5765,6 @@ "empty": "Brak osiągnięć — zapisuj dalej, aby odblokować pierwsze." } }, - "bugreport": { - "title": "Zgłoszenie błędu", - "subtitle": "Wyślij zgłoszenie błędu, sugestię funkcji lub pytanie. Administrator serwera zobaczy twoje zgłoszenie.", - "loginRequired": "Zaloguj się, aby zgłosić błąd.", - "bugTitle": "Tytuł", - "description": "Opis", - "submit": "Wyślij", - "success": "Dziękujemy! Twoje zgłoszenie zostało wysłane.", - "errorCreating": "Błąd tworzenia", - "category": "Rodzaj", - "categoryBug": "Błąd", - "categoryFeature": "Propozycja funkcji", - "categoryQuestion": "Pytanie", - "categoryOther": "Inna opinia", - "bugTitlePlaceholder": "Krótkie podsumowanie (opcjonalnie)", - "githubEscalationNote": "Twoja opinia zapisuje się lokalnie. Jeśli administrator włączy integrację z GitHubem, może zostać opublikowana jako publiczne issue na życzenie.", - "internalOnlyNote": "Twoja opinia zapisuje się lokalnie i jest widoczna tylko dla administratora tego serwera.", - "disabledTitle": "Zgłoszenia błędów są wyłączone", - "disabledBody": "Administrator wyłączył zgłoszenia błędów na tym serwerze." - }, "notifications": { "title": "Powiadomienia", "eventColumn": "Zdarzenie", @@ -5955,7 +5869,8 @@ "eventMeasurementReminder": "Przypomnienie o pomiarze", "eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.", "eventMedicationIntakeSync": "Synchronizacja przyjęć leku", - "eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta." + "eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.", + "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami." }, "i18n": { "maintainershipBanner": { diff --git a/prisma/migrations/0205_v123_drop_bugreport/migration.sql b/prisma/migrations/0205_v123_drop_bugreport/migration.sql new file mode 100644 index 000000000..00e4a2333 --- /dev/null +++ b/prisma/migrations/0205_v123_drop_bugreport/migration.sql @@ -0,0 +1,17 @@ +-- v1.23 — remove the bug-report integration. +-- +-- The in-app bug-report / feedback surface (user form, admin inbox, optional +-- GitHub-issue escalation) is retired. This migration drops the dedicated +-- feedback table, its enums, and the three AppSettings columns that held the +-- GitHub-issue credentials and the enable toggle. +-- +-- Destructive by design: the feature is gone, so its stored data goes with it. +DROP TABLE IF EXISTS "feedback"; + +DROP TYPE IF EXISTS "feedback_status"; +DROP TYPE IF EXISTS "feedback_category"; + +ALTER TABLE "app_settings" + DROP COLUMN IF EXISTS "github_issue_token_encrypted", + DROP COLUMN IF EXISTS "github_issue_repo", + DROP COLUMN IF EXISTS "bug_report_enabled"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf517cb88..789ef9619 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -606,7 +606,6 @@ model User { pushSubscriptions PushSubscription[] dataBackups DataBackup[] achievements UserAchievement[] - feedback Feedback[] recommendationFeedback RecommendationFeedback[] idempotencyKeys IdempotencyKey[] devices Device[] @@ -3204,13 +3203,6 @@ model AppSettings { glitchtipEnabled Boolean @default(false) @map("glitchtip_enabled") glitchtipDsn String? @map("glitchtip_dsn") glitchtipEnvironment String? @map("glitchtip_environment") - githubIssueTokenEncrypted String? @map("github_issue_token_encrypted") - githubIssueRepo String? @map("github_issue_repo") - // v1.4.3: explicit toggle so an admin can hide the bug-report - // surface without nuking the token+repo config. Default true so - // existing instances with credentials don't accidentally lose - // the feature on upgrade. - bugReportEnabled Boolean @default(true) @map("bug_report_enabled") // Medication reminder thresholds (minutes after windowEnd) reminderLateMinutes Int @default(120) @map("reminder_late_minutes") @@ -3281,52 +3273,6 @@ model AppSettings { @@map("app_settings") } -// ─── Built-in Feedback ─────────────────────────────────────── - -enum FeedbackCategory { - BUG - FEATURE_REQUEST - QUESTION - OTHER - - @@map("feedback_category") -} - -enum FeedbackStatus { - OPEN - ACKNOWLEDGED - RESOLVED - ARCHIVED - - @@map("feedback_status") -} - -/// Stores user-submitted feedback without requiring external GitHub config. -/// Admin inbox lives at /admin#feedback; optional escalation publishes to a -/// GitHub issue reusing the existing integration when configured. -model Feedback { - id String @id @default(cuid()) - userId String? @map("user_id") // Null after user deletion; email snapshot remains. - email String? // Snapshot at submission time for triage continuity. - category FeedbackCategory @default(BUG) - subject String - description String - screenshotBase64 String? @map("screenshot_base64") // Optional, <5MB, PNG/JPG. - metadata Json? // URL, userAgent, locale, appVersion, … - status FeedbackStatus @default(OPEN) - adminNote String? @map("admin_note") - gitHubIssueUrl String? @map("github_issue_url") - notifiedEmail Boolean @default(false) @map("notified_email") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - - @@index([status, createdAt]) - @@index([userId]) - @@map("feedback") -} - // ─── Audit Log ───────────────────────────────────────────── model AuditLog { diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 437ef7362..79e11d648 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -227,11 +227,9 @@ async function main() { // ───── AppSettings — operator credentials (String) ───── // Columns: "adminAiKeyEncrypted" "webPushVapidPrivateKeyEncrypted" - // "githubIssueTokenEncrypted" for (const field of [ "adminAiKeyEncrypted", "webPushVapidPrivateKeyEncrypted", - "githubIssueTokenEncrypted", ]) { results.push( await rotateStringColumn("AppSettings", field, prisma.appSettings), diff --git a/src/app/achievements/__tests__/page.test.tsx b/src/app/achievements/__tests__/page.test.tsx index 840ecadc5..93f3149c0 100644 --- a/src/app/achievements/__tests__/page.test.tsx +++ b/src/app/achievements/__tests__/page.test.tsx @@ -137,7 +137,6 @@ const mockData = { passkeyLoginCount: 0, passwordLoginCount: 0, loginDayStreak: 0, - bugReportCount: 0, moodEntryCount: 0, moodDayStreak: 0, moodImprovementHit: 0, diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 4e87add9e..86d815bf3 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -25,7 +25,6 @@ import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-s import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section"; import { BackupsSection } from "@/components/admin/backups-section"; import { DangerZoneSection } from "@/components/admin/danger-zone-section"; -import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section"; import { GeneralSettingsSection } from "@/components/admin/general-settings-section"; import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section"; import { LoginOverviewSection } from "@/components/admin/login-overview-section"; @@ -120,15 +119,6 @@ export function AdminSectionRenderer({ ); - case "feedback": - return ( - - - - ); case "reminders": return ( ({ - prisma: { - feedback: { - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { PATCH } from "../route"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_ADMIN = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "admin-1", username: "admin", role: "ADMIN" as const }, -}; - -function patchReq(body: unknown): NextRequest { - return new NextRequest("http://localhost/api/admin/feedback/f1", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); -} - -const ROUTE_CTX = { params: Promise.resolve({ id: "f1" }) }; - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_ADMIN as never); -}); - -describe("PATCH /api/admin/feedback/[id] — 422 multi-issue (v1.4.43 W6)", () => { - it("surfaces TWO simultaneous validation errors", async () => { - // Bad `status` enum + adminNote too long. - const res = await PATCH( - patchReq({ status: "junk", adminNote: "x".repeat(10000) }), - ROUTE_CTX, - ); - expect(res.status).toBe(422); - const body = (await res.json()) as { - data: null; - error: string; - details: { - issues: Array<{ path: string; code: string; message: string }>; - }; - }; - expect(body.data).toBeNull(); - expect(body.error).toBe("Validation failed"); - expect(body.details.issues.length).toBeGreaterThanOrEqual(2); - for (const issue of body.details.issues) { - expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); - } - }); - - it("surfaces multiple simultaneous validation errors (≥ 2)", async () => { - // The schema has only two knobs (status + adminNote), so the strict - // 3-issue ceiling isn't reachable. Multi-issue contract is pinned at - // ≥ 2 by the case above. - const res = await PATCH( - patchReq({ status: 999, adminNote: 123 }), - ROUTE_CTX, - ); - expect(res.status).toBe(422); - const body = (await res.json()) as { - details: { issues: Array }; - }; - expect(body.details.issues.length).toBeGreaterThanOrEqual(2); - }); -}); diff --git a/src/app/api/admin/feedback/[id]/github/route.ts b/src/app/api/admin/feedback/[id]/github/route.ts deleted file mode 100644 index bf4f6111b..000000000 --- a/src/app/api/admin/feedback/[id]/github/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * POST /api/admin/feedback/[id]/github — promote feedback to a GitHub Issue. - * - * Reuses publishFeedbackToGithub() so the issue body matches the legacy - * /api/bugreport flow. Idempotent: if gitHubIssueUrl is already set we return - * the existing URL instead of creating a duplicate. - */ -import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { prisma } from "@/lib/db"; -import { - publishFeedbackToGithub, - GithubPublishError, -} from "@/lib/feedback/publish-github"; -import type { NextRequest } from "next/server"; - -export const POST = apiHandler( - async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { - const { user: admin } = await requireAdmin(); - const { id } = await ctx.params; - - const feedback = await prisma.feedback.findUnique({ - where: { id }, - select: { - id: true, - category: true, - subject: true, - description: true, - gitHubIssueUrl: true, - screenshotBase64: true, - metadata: true, - user: { select: { username: true } }, - }, - }); - if (!feedback) return apiError("Feedback not found", 404); - - if (feedback.gitHubIssueUrl) { - return apiError(`Already published: ${feedback.gitHubIssueUrl}`, 409); - } - - // Atomic claim to prevent concurrent admin clicks from creating two - // duplicate GitHub issues. Only the first caller whose updateMany - // matches (gitHubIssueUrl still null) proceeds. We write a PENDING - // sentinel now and replace it with the real URL after the API call. - const PENDING = "pending://creating"; - const claim = await prisma.feedback.updateMany({ - where: { id, gitHubIssueUrl: null }, - data: { gitHubIssueUrl: PENDING }, - }); - if (claim.count !== 1) { - return apiError("Already being published by another request", 409); - } - - try { - const result = await publishFeedbackToGithub({ - category: feedback.category, - subject: feedback.subject, - description: feedback.description, - username: feedback.user?.username ?? "anonymous", - metadata: feedback.metadata, - }); - - await prisma.feedback.update({ - where: { id }, - data: { gitHubIssueUrl: result.issueUrl }, - }); - - await auditLog("admin.feedback.github_publish", { - userId: admin.id, - ipAddress: getClientIp(request), - details: { feedbackId: id, issueNumber: result.issueNumber }, - }); - - annotate({ - action: { name: "admin.feedback.github_publish" }, - meta: { feedback_id: id, issue_number: result.issueNumber }, - }); - - return apiSuccess({ issueUrl: result.issueUrl }); - } catch (err) { - // Release the PENDING claim so a retry can succeed. - await prisma.feedback - .updateMany({ - where: { id, gitHubIssueUrl: PENDING }, - data: { gitHubIssueUrl: null }, - }) - .catch(() => undefined); - if (err instanceof GithubPublishError) { - return apiError(err.message, err.status); - } - throw err; - } - }, -); diff --git a/src/app/api/admin/feedback/[id]/route.ts b/src/app/api/admin/feedback/[id]/route.ts deleted file mode 100644 index ede8425f6..000000000 --- a/src/app/api/admin/feedback/[id]/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * PATCH /api/admin/feedback/[id] — change status or admin note. - * DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED). - */ -import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, - getClientIp, -} from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { prisma } from "@/lib/db"; -import { updateFeedbackSchema } from "@/lib/validations/feedback"; -import type { NextRequest } from "next/server"; - -export const PATCH = apiHandler( - async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { - const { user } = await requireAdmin(); - const { id } = await ctx.params; - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = updateFeedbackSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } - - const before = await prisma.feedback.findUnique({ where: { id } }); - if (!before) return apiError("Feedback not found", 404); - - const updated = await prisma.feedback.update({ - where: { id }, - data: parsed.data, - select: { - id: true, - status: true, - adminNote: true, - updatedAt: true, - }, - }); - - await auditLog("admin.feedback.update", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - feedbackId: id, - before: { status: before.status, adminNote: before.adminNote }, - after: parsed.data, - }, - }); - - annotate({ - action: { name: "admin.feedback.update" }, - meta: { feedback_id: id }, - }); - - return apiSuccess(updated); - }, -); - -export const DELETE = apiHandler( - async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { - const { user } = await requireAdmin(); - const { id } = await ctx.params; - - const updated = await prisma.feedback.update({ - where: { id }, - data: { status: "ARCHIVED" }, - select: { id: true, status: true }, - }); - - await auditLog("admin.feedback.archive", { - userId: user.id, - ipAddress: getClientIp(request), - details: { feedbackId: id }, - }); - - return apiSuccess(updated); - }, -); diff --git a/src/app/api/admin/feedback/route.ts b/src/app/api/admin/feedback/route.ts deleted file mode 100644 index cf5f55f8a..000000000 --- a/src/app/api/admin/feedback/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Admin feedback inbox. Lists all feedback with status filter. - */ -import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { prisma } from "@/lib/db"; -import type { NextRequest } from "next/server"; -import type { FeedbackStatus } from "@/generated/prisma/client"; - -export const GET = apiHandler(async (request: NextRequest) => { - await requireAdmin(); - - const url = new URL(request.url); - const status = url.searchParams.get("status") as FeedbackStatus | null; - const limit = Math.min( - 100, - Math.max(1, Number(url.searchParams.get("limit") ?? 50)), - ); - const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0)); - - const where = status ? { status } : {}; - - const [items, total, counts] = await Promise.all([ - prisma.feedback.findMany({ - where, - orderBy: { createdAt: "desc" }, - take: limit, - skip: offset, - select: { - id: true, - userId: true, - email: true, - category: true, - subject: true, - description: true, - status: true, - adminNote: true, - gitHubIssueUrl: true, - metadata: true, - screenshotBase64: true, - createdAt: true, - updatedAt: true, - user: { select: { username: true } }, - }, - }), - prisma.feedback.count({ where }), - prisma.feedback.groupBy({ - by: ["status"], - _count: { _all: true }, - }), - ]); - - annotate({ - action: { name: "admin.feedback.list" }, - meta: { total, filtered_status: status }, - }); - - const countsByStatus = Object.fromEntries( - counts.map((c) => [c.status, c._count._all]), - ) as Record; - - return apiSuccess({ - items, - meta: { total, limit, offset, countsByStatus }, - }); -}); diff --git a/src/app/api/admin/settings/__tests__/route.test.ts b/src/app/api/admin/settings/__tests__/route.test.ts index 7c8dd66e8..599f02ab4 100644 --- a/src/app/api/admin/settings/__tests__/route.test.ts +++ b/src/app/api/admin/settings/__tests__/route.test.ts @@ -98,7 +98,6 @@ describe("GET /api/admin/settings", () => { expect(body.data.registrationEnabled).toBe(true); expect(body.data.defaultLocale).toBe("de"); expect(body.data.webPushVapidConfigured).toBe(false); - expect(body.data.bugReportConfigured).toBe(false); expect(body.data.glitchtipEnabled).toBe(false); }); @@ -120,8 +119,6 @@ describe("GET /api/admin/settings", () => { glitchtipEnabled: false, glitchtipDsn: null, glitchtipEnvironment: "production", - githubIssueRepo: "owner/repo", - githubIssueTokenEncrypted: "token-blob", reminderLateMinutes: 120, reminderMissedMinutes: 240, moodLogGlobal: true, @@ -129,15 +126,12 @@ describe("GET /api/admin/settings", () => { const res = await GET(); const body = (await res.json()) as { data: Record }; - // The two configured-flags must be true … + // The configured flag must be true … expect(body.data.webPushVapidConfigured).toBe(true); - expect(body.data.bugReportConfigured).toBe(true); // … but raw encrypted blobs must never reach the client. const serialized = JSON.stringify(body.data); expect(serialized).not.toContain("secret-blob"); - expect(serialized).not.toContain("token-blob"); expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted"); - expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted"); }); }); @@ -185,8 +179,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnabled: false, glitchtipDsn: null, glitchtipEnvironment: "production", - githubIssueRepo: null, - githubIssueTokenEncrypted: null, reminderLateMinutes: 120, reminderMissedMinutes: 240, moodLogGlobal: true, @@ -227,8 +219,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnabled: false, glitchtipDsn: null, glitchtipEnvironment: null, - githubIssueRepo: null, - githubIssueTokenEncrypted: null, reminderLateMinutes: 120, reminderMissedMinutes: 240, moodLogGlobal: true, @@ -257,8 +247,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnabled: false, glitchtipDsn: null, glitchtipEnvironment: null, - githubIssueRepo: null, - githubIssueTokenEncrypted: null, reminderLateMinutes: 120, reminderMissedMinutes: 240, moodLogGlobal: true, @@ -292,8 +280,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnabled: true, glitchtipDsn: "https://abc@glitchtip.example.com/1", glitchtipEnvironment: "staging", - githubIssueRepo: "owner/repo", - githubIssueTokenEncrypted: "enc(gh-token)", reminderLateMinutes: 30, reminderMissedMinutes: 90, moodLogGlobal: false, @@ -317,8 +303,6 @@ describe("PUT /api/admin/settings", () => { umamiWebsiteId: "site-uuid", glitchtipDsn: "https://abc@glitchtip.example.com/1", glitchtipEnvironment: "staging", - bugReportRepo: "owner/repo", - bugReportToken: "gh-token", reminderLateMinutes: 30, reminderMissedMinutes: 90, }), @@ -336,8 +320,6 @@ describe("PUT /api/admin/settings", () => { umamiWebsiteId: "site-uuid", glitchtipDsn: "https://abc@glitchtip.example.com/1", glitchtipEnvironment: "staging", - githubIssueRepo: "owner/repo", - githubIssueTokenEncrypted: "enc(gh-token)", webPushVapidPublicKey: "vapid-pub", webPushVapidSubject: "mailto:ops@healthlog.dev", webPushVapidPrivateKeyEncrypted: "enc(secret)", @@ -345,7 +327,6 @@ describe("PUT /api/admin/settings", () => { reminderMissedMinutes: 90, }); expect(encrypt).toHaveBeenCalledWith("secret"); - expect(encrypt).toHaveBeenCalledWith("gh-token"); }); it("clears nullable string fields when an empty string is submitted", async () => { @@ -366,8 +347,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnabled: false, glitchtipDsn: null, glitchtipEnvironment: null, - githubIssueRepo: null, - githubIssueTokenEncrypted: null, reminderLateMinutes: 120, reminderMissedMinutes: 240, moodLogGlobal: true, @@ -381,7 +360,6 @@ describe("PUT /api/admin/settings", () => { umamiWebsiteId: "", glitchtipDsn: "", glitchtipEnvironment: "", - bugReportRepo: "", }), ); const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; @@ -392,39 +370,9 @@ describe("PUT /api/admin/settings", () => { umamiWebsiteId: null, glitchtipDsn: null, glitchtipEnvironment: null, - githubIssueRepo: null, }); }); - it("clears the encrypted bug-report token when clear flag is set", async () => { - vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ - id: "singleton", - registrationEnabled: true, - defaultLocale: "en", - telegramGlobal: true, - ntfyGlobal: true, - webPushGlobal: true, - webPushVapidPublicKey: null, - webPushVapidSubject: null, - webPushVapidPrivateKeyEncrypted: null, - apiGlobal: true, - umamiEnabled: false, - umamiScriptUrl: null, - umamiWebsiteId: null, - glitchtipEnabled: false, - glitchtipDsn: null, - glitchtipEnvironment: null, - githubIssueRepo: null, - githubIssueTokenEncrypted: null, - reminderLateMinutes: 120, - reminderMissedMinutes: 240, - moodLogGlobal: true, - } as never); - await PUT(jsonReq({ clearBugReportToken: true })); - const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; - expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null }); - }); - it("returns 415 when content-type is not JSON", async () => { const r = new NextRequest("http://localhost/api/admin/settings", { method: "PUT", @@ -454,9 +402,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnabled: false, glitchtipDsn: null, glitchtipEnvironment: null, - githubIssueRepo: null, - githubIssueTokenEncrypted: null, - bugReportEnabled: true, reminderLateMinutes: 120, reminderMissedMinutes: 240, moodLogGlobal: true, @@ -486,9 +431,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnabled: false, glitchtipDsn: null, glitchtipEnvironment: null, - githubIssueRepo: null, - githubIssueTokenEncrypted: null, - bugReportEnabled: true, reminderLateMinutes: 120, reminderMissedMinutes: 240, moodLogGlobal: true, diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index ac276b313..2dc0acb19 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -16,7 +16,6 @@ import { invalidateServerDefaultTimezone, isValidTimezone, } from "@/lib/tz/resolver"; -import { invalidateAppSettings } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -48,11 +47,6 @@ export const GET = apiHandler(async () => { glitchtipEnabled: settings?.glitchtipEnabled ?? false, glitchtipDsn: settings?.glitchtipDsn ?? null, glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production", - bugReportRepo: settings?.githubIssueRepo ?? null, - bugReportConfigured: Boolean( - settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted, - ), - bugReportEnabled: settings?.bugReportEnabled ?? true, reminderLateMinutes: settings?.reminderLateMinutes ?? 120, reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240, moodLogGlobal: settings?.moodLogGlobal ?? true, @@ -92,7 +86,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { "umamiEnabled", "glitchtipEnabled", "moodLogGlobal", - "bugReportEnabled", ] as const; for (const field of booleanFields) { if (data[field] !== undefined) { @@ -171,24 +164,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { auditDetails.glitchtipEnvironment = value || null; } - // Bug report - if (data.bugReportRepo !== undefined) { - const repo = data.bugReportRepo.trim(); - updates.githubIssueRepo = repo || null; - auditDetails.bugReportRepo = repo || null; - } - if (data.bugReportToken !== undefined) { - const token = data.bugReportToken.trim(); - if (token) { - updates.githubIssueTokenEncrypted = encrypt(token); - auditDetails.bugReportTokenUpdated = true; - } - } - if (data.clearBugReportToken === true) { - updates.githubIssueTokenEncrypted = null; - auditDetails.bugReportTokenUpdated = false; - } - // Numeric thresholds if (data.reminderLateMinutes !== undefined) { updates.reminderLateMinutes = data.reminderLateMinutes; @@ -233,11 +208,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { invalidateServerDefaultTimezone(); } - // v1.4.34 IW-G — bust the bug-report status cache (global singleton) - // so the next read reflects the new GitHub-token / bug-report-enabled - // shape. Cheap call — the cache holds at most 10 entries. - invalidateAppSettings(); - await auditLog("admin.settings.update", { userId: user.id, ipAddress: getClientIp(request), @@ -264,11 +234,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { glitchtipEnabled: settings.glitchtipEnabled, glitchtipDsn: settings.glitchtipDsn, glitchtipEnvironment: settings.glitchtipEnvironment ?? "production", - bugReportRepo: settings.githubIssueRepo, - bugReportConfigured: Boolean( - settings.githubIssueRepo && settings.githubIssueTokenEncrypted, - ), - bugReportEnabled: settings.bugReportEnabled, reminderLateMinutes: settings.reminderLateMinutes, reminderMissedMinutes: settings.reminderMissedMinutes, moodLogGlobal: settings.moodLogGlobal, diff --git a/src/app/api/admin/settings/web-push-vapid/generate/route.ts b/src/app/api/admin/settings/web-push-vapid/generate/route.ts index 2e7556dcd..db647d982 100644 --- a/src/app/api/admin/settings/web-push-vapid/generate/route.ts +++ b/src/app/api/admin/settings/web-push-vapid/generate/route.ts @@ -9,7 +9,6 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; -import { invalidateAppSettings } from "@/lib/cache/invalidate"; import { z } from "zod/v4"; import { NextRequest } from "next/server"; @@ -114,8 +113,6 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - invalidateAppSettings(); - await auditLog("admin.web_push_vapid.generate", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index 7bcf4b8f2..3406c07a1 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -89,11 +89,10 @@ describe("GET /api/admin/status (integration health summary)", () => { umami: null, glitchtip: null, webPush: null, - bugReport: null, }); }); - it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => { + it("aggregates configured + enabled flags for umami / glitchtip / web-push", async () => { vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ umamiScriptUrl: "https://x/script.js", umamiWebsiteId: "site-1", @@ -103,8 +102,6 @@ describe("GET /api/admin/status (integration health summary)", () => { webPushVapidPublicKey: "pub", webPushVapidPrivateKeyEncrypted: "secret-blob", webPushVapidSubject: "mailto:a@b.com", - githubIssueRepo: "owner/repo", - githubIssueTokenEncrypted: "token-blob", } as never); const res = await GET(); @@ -121,7 +118,6 @@ describe("GET /api/admin/status (integration health summary)", () => { enabled: false, }); expect(body.data.integrations.webPush).toEqual({ configured: true }); - expect(body.data.integrations.bugReport).toEqual({ configured: true }); // Regression: encrypted blobs MUST never appear in the response envelope. const serialized = JSON.stringify(body); diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index 02389f0e9..15ae3331b 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -56,10 +56,6 @@ export const GET = apiHandler(async () => { appSettings?.webPushVapidPrivateKeyEncrypted && appSettings?.webPushVapidSubject, ); - const bugReportConfigured = Boolean( - appSettings?.githubIssueRepo && appSettings?.githubIssueTokenEncrypted, - ); - const workerStatus = getWorkerStatus(); return apiSuccess({ @@ -89,7 +85,6 @@ export const GET = apiHandler(async () => { } : null, webPush: webPushConfigured ? { configured: true } : null, - bugReport: bugReportConfigured ? { configured: true } : null, }, }); }); diff --git a/src/app/api/bugreport/__tests__/route.test.ts b/src/app/api/bugreport/__tests__/route.test.ts deleted file mode 100644 index f1ef98855..000000000 --- a/src/app/api/bugreport/__tests__/route.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * v1.4.43 W6 — multi-issue 422 envelope on POST /api/bugreport. - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/db", () => ({ - prisma: { - appSettings: { - findUnique: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(), -})); -vi.mock("@/lib/crypto", () => ({ - decrypt: (s: string) => s.replace(/^enc-/, ""), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { POST } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { checkRateLimit } from "@/lib/rate-limit"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "tester", role: "USER" as const }, -}; - -function postReq(body: unknown): NextRequest { - return new NextRequest("http://localhost/api/bugreport", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ - githubIssueTokenEncrypted: "enc-token", - githubIssueRepo: "owner/repo", - bugReportEnabled: true, - } as never); -}); - -describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => { - it("surfaces TWO simultaneous validation errors", async () => { - // description too short + screenshot doesn't match data-URL regex. - const res = await POST( - postReq({ description: "tiny", screenshot: "not-a-data-url" }), - ); - expect(res.status).toBe(422); - const body = (await res.json()) as { - data: null; - error: string; - details: { - issues: Array<{ path: string; code: string; message: string }>; - }; - }; - expect(body.data).toBeNull(); - expect(body.error).toBe("Validation failed"); - expect(body.details.issues.length).toBeGreaterThanOrEqual(2); - for (const issue of body.details.issues) { - expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); - } - }); - - it("surfaces multiple simultaneous validation errors (≥ 2)", async () => { - // Schema has only two knobs (description, screenshot). Pin contract - // on ≥ 2. - const res = await POST(postReq({ description: 123, screenshot: 999 })); - expect(res.status).toBe(422); - const body = (await res.json()) as { - details: { issues: Array }; - }; - expect(body.details.issues.length).toBeGreaterThanOrEqual(2); - }); -}); diff --git a/src/app/api/bugreport/route.ts b/src/app/api/bugreport/route.ts deleted file mode 100644 index 9f2c24fa2..000000000 --- a/src/app/api/bugreport/route.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate, getEvent } from "@/lib/logging/context"; -import { - apiSuccess, - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { auditLog } from "@/lib/auth/audit"; -import { prisma } from "@/lib/db"; -import { decrypt } from "@/lib/crypto"; -import { safeFetch } from "@/lib/safe-fetch"; -import { NextRequest } from "next/server"; -import { z } from "zod/v4"; - -// Strict image/* data URL (no SVG — it can carry inline JS). -const SCREENSHOT_DATA_URL = - /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/; - -const bugReportSchema = z.object({ - description: z.string().min(10, "Description too short").max(5000), - screenshot: z - .string() - .max(7_000_000, "Screenshot too large (max 5 MB)") - .regex( - SCREENSHOT_DATA_URL, - "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL", - ) - .optional(), -}); - -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - - const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000); - if (!rl.allowed) { - return apiError("Maximum 3 bug reports per hour", 429); - } - - annotate({ action: { name: "bugreport.submit" } }); - - const appSettings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { - githubIssueTokenEncrypted: true, - githubIssueRepo: true, - bugReportEnabled: true, - }, - }); - - // v1.4.3: explicit toggle. When the admin disables bug reports we - // return 503 immediately — the client already renders a "not - // configured" UI for that status, so the report button gracefully - // disappears without needing additional client-side wiring. - if (appSettings && appSettings.bugReportEnabled === false) { - return apiError("Bug reports are disabled by the administrator", 503); - } - - let configuredToken: string | null = null; - if (appSettings?.githubIssueTokenEncrypted) { - try { - configuredToken = decrypt(appSettings.githubIssueTokenEncrypted); - } catch { - getEvent()?.addWarning("Failed to decrypt GitHub issue token"); - } - } - - const ghToken = configuredToken || process.env.GITHUB_ISSUE_TOKEN; - const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo" - - if (!ghToken || !ghRepo) { - // 503 instead of 500: the client keys off status to render the "not - // configured" screen. The user also sees a clear message rather than - // "Internal server error". - return apiError( - "Bug report not configured (GitHub issue token/repository missing in admin settings)", - 503, - ); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 8 * 1024 * 1024, - }); - - if (jsonError) return jsonError; - const parsed = bugReportSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } - - const { description, screenshot } = parsed.data; - - // GitHub issues are maintainer-facing and the repo's primary language is English. - // Use an ISO-8601 timestamp so titles are unambiguous and sortable. - const now = new Date(); - const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC"; - const title = `Bug Report – ${dateStr}`; - - // Sanitize user-provided content for GitHub markdown. Strip HTML tags - // entirely (prevents `` etc.) and neutralise markdown link - // targets that start with dangerous schemes like javascript: or data:. - const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, ""); - const safeDescription = description - .replace(/<[^>]*>/g, "") // strip HTML tags - .replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes - .replace(/```/g, "\\`\\`\\`") - .slice(0, 5000); - - // Wrap the user's description in a fenced code block so any residual - // markdown renders literally rather than as executable markup. - let issueBody = `**Reported by:** ${safeUsername}\n`; - issueBody += `**Date:** ${dateStr}\n\n`; - issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`; - issueBody += `\n---\n*Created via HealthLog bug report*`; - - // If screenshot provided, upload to GitHub as a gist or include as base64 - // For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64 - if (screenshot) { - issueBody += `\n\n**Screenshot:** A screenshot was attached (see comment).`; - } - - // Create the issue - const issueRes = await safeFetch( - `https://api.github.com/repos/${ghRepo}/issues`, - { - method: "POST", - headers: { - Authorization: `Bearer ${ghToken}`, - "Content-Type": "application/json", - Accept: "application/vnd.github.v3+json", - }, - body: JSON.stringify({ - title: `[Bug] ${title}`, - body: issueBody, - labels: ["bug", "user-reported"], - }), - }, - ); - - if (!issueRes.ok) { - const errBody = await issueRes.text(); - getEvent()?.addWarning("GitHub issue creation failed: " + errBody); - return apiError("Failed to create issue", 500); - } - - const issue = (await issueRes.json()) as { - number: number; - html_url: string; - }; - - // If screenshot, upload as a comment - if (screenshot) { - // Extract just the base64 part - const base64Data = screenshot.replace(/^data:image\/\w+;base64,/, ""); - const ext = screenshot.startsWith("data:image/png") ? "png" : "jpg"; - - // Upload screenshot as issue comment with embedded image - // GitHub doesn't support base64 in comments, so we note the limitation - const commentBody = `### Screenshot\n\n![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`; - - await safeFetch( - `https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`, - { - method: "POST", - headers: { - Authorization: `Bearer ${ghToken}`, - "Content-Type": "application/json", - Accept: "application/vnd.github.v3+json", - }, - body: JSON.stringify({ body: commentBody }), - }, - ); - } - - await auditLog("bugreport.submit", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - issueNumber: issue.number, - hasScreenshot: Boolean(screenshot), - }, - }); - - annotate({ meta: { issue_number: issue.number } }); - - return apiSuccess({ - issueNumber: issue.number, - issueUrl: issue.html_url, - }); -}); diff --git a/src/app/api/bugreport/status/route.ts b/src/app/api/bugreport/status/route.ts deleted file mode 100644 index c54ce11b0..000000000 --- a/src/app/api/bugreport/status/route.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Returns whether bug report submission is configured (GitHub token + repo). - * - * The bug report page uses this to gate its UI so users don't hit a silent - * 500 when the admin hasn't set up the GitHub integration. Only two booleans - * escape: a single `configured` flag and `isAdmin` so the page can render - * the admin-specific copy + admin link. Token/repo granularity is kept - * server-side (log annotation only) so we don't hand regular users a probe. - */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { prisma } from "@/lib/db"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; - -interface BugreportStatusCacheValue { - configured: boolean; - enabled: boolean; - hasToken: boolean; - hasRepo: boolean; -} - -async function buildBugreportStatusValue(): Promise { - const appSettings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { - githubIssueTokenEncrypted: true, - githubIssueRepo: true, - bugReportEnabled: true, - }, - }); - - const hasToken = Boolean( - appSettings?.githubIssueTokenEncrypted || process.env.GITHUB_ISSUE_TOKEN, - ); - const hasRepo = Boolean( - appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO, - ); - const configured = hasToken && hasRepo; - // Default ON when the column has never been written. - const enabled = appSettings?.bugReportEnabled !== false; - - return { configured, enabled, hasToken, hasRepo }; -} - -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); - - // No per-request rate limit: the payload rides the singleton server - // cache below, so a cache hit costs zero Postgres round-trips — while - // the former rate-limit bucket was an unconditional Postgres UPSERT - // per request, i.e. the limiter WAS the per-request DB load it claimed - // to prevent. The route stays authenticated; the only uncached work is - // one `appSettings` read per 10 min (the bucket's TTL) process-wide. - - // Cache the global app-settings shape on a singleton key (per blueprint §3). - // `isAdmin` lives outside the cache because it varies per request. The - // `invalidateAppSettings()` helper called from - // `PATCH /api/admin/app-settings` evicts this entry when bug-report - // configuration changes. - const status = await cached( - caches.bugreportStatus as ServerCache, - "singleton", - () => buildBugreportStatusValue(), - annotate, - ); - - annotate({ - action: { name: "bugreport.status" }, - meta: { - configured: status.configured, - enabled: status.enabled, - has_token: status.hasToken, - has_repo: status.hasRepo, - }, - }); - - return apiSuccess({ - configured: status.configured, - enabled: status.enabled, - isAdmin: user.role === "ADMIN", - }); -}); diff --git a/src/app/api/feedback/__tests__/route.test.ts b/src/app/api/feedback/__tests__/route.test.ts deleted file mode 100644 index 080359d80..000000000 --- a/src/app/api/feedback/__tests__/route.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, - requireAuth: vi.fn(async () => ({ - user: { id: "u-1", email: "test@example.com", role: "USER" }, - session: { id: "s-1" }, - })), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - appSettings: { findUnique: vi.fn() }, - feedback: { create: vi.fn() }, - }, -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(async () => ({ allowed: true })), -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), -})); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn(async () => undefined), -})); - -import { POST } from "../route"; -import { prisma } from "@/lib/db"; - -beforeEach(() => { - vi.resetAllMocks(); -}); - -function jsonRequest(body: unknown): Request { - return new Request("http://localhost/api/feedback", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); -} - -describe("POST /api/feedback bug-report toggle gate", () => { - it("returns 503 when bugReportEnabled is false", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ - bugReportEnabled: false, - } as never); - - const res = await POST( - jsonRequest({ - category: "BUG", - subject: "test", - description: "Anything at least ten characters long.", - }) as never, - ); - expect(res.status).toBe(503); - const body = (await res.json()) as { error: string }; - expect(body.error).toMatch(/disabled/i); - expect(prisma.feedback.create).not.toHaveBeenCalled(); - }); - - it("creates feedback when bugReportEnabled is true", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ - bugReportEnabled: true, - } as never); - vi.mocked(prisma.feedback.create).mockResolvedValue({ - id: "fb-1", - createdAt: new Date(), - category: "BUG", - status: "NEW", - } as never); - - const res = await POST( - jsonRequest({ - category: "BUG", - subject: "test", - description: "Anything at least ten characters long.", - }) as never, - ); - expect(res.status).toBe(201); - expect(prisma.feedback.create).toHaveBeenCalledTimes(1); - }); - - it("creates feedback when bugReportEnabled has never been written (default ON)", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); - vi.mocked(prisma.feedback.create).mockResolvedValue({ - id: "fb-2", - createdAt: new Date(), - category: "BUG", - status: "NEW", - } as never); - - const res = await POST( - jsonRequest({ - category: "BUG", - subject: "test", - description: "Anything at least ten characters long.", - }) as never, - ); - expect(res.status).toBe(201); - expect(prisma.feedback.create).toHaveBeenCalledTimes(1); - }); -}); - -describe("POST /api/feedback — 422 multi-issue (v1.4.43 W6)", () => { - it("surfaces TWO simultaneous validation errors", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ - bugReportEnabled: true, - } as never); - // subject too short + description too short. - const res = await POST( - jsonRequest({ - category: "BUG", - subject: "x", - description: "short", - }) as never, - ); - expect(res.status).toBe(422); - const body = (await res.json()) as { - data: null; - error: string; - details: { - issues: Array<{ path: string; code: string; message: string }>; - }; - }; - expect(body.data).toBeNull(); - expect(body.error).toBe("Validation failed"); - expect(body.details.issues.length).toBeGreaterThanOrEqual(2); - for (const issue of body.details.issues) { - expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); - } - }); - - it("surfaces THREE simultaneous validation errors", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ - bugReportEnabled: true, - } as never); - // bad subject + bad description + bad screenshot. - const res = await POST( - jsonRequest({ - category: "BUG", - subject: "x", - description: "short", - screenshot: "not-a-data-url", - }) as never, - ); - expect(res.status).toBe(422); - const body = (await res.json()) as { - details: { issues: Array }; - }; - expect(body.details.issues.length).toBeGreaterThanOrEqual(3); - }); -}); diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts deleted file mode 100644 index 2547bb3af..000000000 --- a/src/app/api/feedback/route.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * POST /api/feedback — user submits feedback. Always works, no GitHub needed. - * GET /api/feedback — user's own feedback history (paginated). - * - * Rate-limited at 5 per hour per user. Screenshots are stored as base64 in - * the database (≤5 MB each). - */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, - getClientIp, -} from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { auditLog } from "@/lib/auth/audit"; -import { prisma } from "@/lib/db"; -import { createFeedbackSchema } from "@/lib/validations/feedback"; -import { Prisma } from "@/generated/prisma/client"; -import type { NextRequest } from "next/server"; - -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - - const rl = await checkRateLimit(`feedback:${user.id}`, 5, 60 * 60 * 1000); - if (!rl.allowed) { - return apiError("Too many submissions — please try again later", 429); - } - - // The admin "Bug reports" toggle gates *every* feedback submission, not - // just the GitHub-promotion path. Previously the gate only lived in the - // legacy `/api/bugreport` route which the form does not even call — - // so flipping the toggle off had no effect on user-visible behaviour. - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { bugReportEnabled: true }, - }); - if (settings && settings.bugReportEnabled === false) { - return apiError("Bug reports are disabled by the administrator", 503); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = createFeedbackSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } - - const feedback = await prisma.feedback.create({ - data: { - userId: user.id, - email: user.email ?? null, - category: parsed.data.category, - subject: parsed.data.subject, - description: parsed.data.description, - screenshotBase64: parsed.data.screenshot ?? null, - metadata: - parsed.data.metadata !== undefined - ? (parsed.data.metadata as Prisma.InputJsonValue) - : Prisma.JsonNull, - }, - select: { id: true, createdAt: true, category: true, status: true }, - }); - - await auditLog("feedback.submit", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - feedbackId: feedback.id, - category: feedback.category, - hasScreenshot: Boolean(parsed.data.screenshot), - }, - }); - - annotate({ - action: { name: "feedback.submit" }, - meta: { - feedback_id: feedback.id, - category: feedback.category, - }, - }); - - return apiSuccess(feedback, 201); -}); - -export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - - const url = new URL(request.url); - const limit = Math.min( - 50, - Math.max(1, Number(url.searchParams.get("limit") ?? 25)), - ); - const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0)); - - const [items, total] = await Promise.all([ - prisma.feedback.findMany({ - where: { userId: user.id }, - orderBy: { createdAt: "desc" }, - take: limit, - skip: offset, - select: { - id: true, - category: true, - subject: true, - status: true, - adminNote: true, - gitHubIssueUrl: true, - createdAt: true, - updatedAt: true, - }, - }), - prisma.feedback.count({ where: { userId: user.id } }), - ]); - - return apiSuccess({ items, meta: { total, limit, offset } }); -}); diff --git a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts index 0504280dd..deb5464b0 100644 --- a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts +++ b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts @@ -190,7 +190,7 @@ describe("GET /api/gamification/achievements — hidden achievement redaction", for (const entry of hiddenLocked) { // Trigger semantics scrubbed expect(entry.metric ?? "").not.toMatch( - /(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip|bugReport)Count/, + /(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip)Count/, ); // i18n keys scrubbed expect(entry.titleKey ?? "").not.toContain("hiddenNightOwl"); diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index d5f0d3da3..28ac74637 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -57,12 +57,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // Destroy all sessions first await destroyAllSessions(userId); - // Audit V3 NEW-V3-2 / GDPR Art. 17 fix: Feedback and AuditLog rows have - // `onDelete: SetNull` in the schema, which keeps PII (free-text symptom - // descriptions, IP addresses, login city geo) attached to the record after - // the user is deleted. We explicitly purge them inside the same logical - // operation so account deletion is genuinely complete erasure. - await prisma.feedback.deleteMany({ where: { userId } }); + // Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have + // `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login + // city geo) attached to the record after the user is deleted. We explicitly + // purge them inside the same logical operation so account deletion is + // genuinely complete erasure. await prisma.auditLog.deleteMany({ where: { userId } }); // Delete user — all other related data is removed via onDelete: Cascade diff --git a/src/app/bugreport/page.tsx b/src/app/bugreport/page.tsx deleted file mode 100644 index ff1898f4a..000000000 --- a/src/app/bugreport/page.tsx +++ /dev/null @@ -1,247 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { useAuth } from "@/hooks/use-auth"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Loader2, CheckCircle2, Bug, GitPullRequest, Info } from "lucide-react"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; - -interface BugReportStatus { - configured: boolean; - enabled: boolean; - isAdmin: boolean; -} - -const CATEGORIES = [ - { value: "BUG", labelKey: "bugreport.categoryBug" }, - { value: "FEATURE_REQUEST", labelKey: "bugreport.categoryFeature" }, - { value: "QUESTION", labelKey: "bugreport.categoryQuestion" }, - { value: "OTHER", labelKey: "bugreport.categoryOther" }, -] as const; - -type CategoryValue = (typeof CATEGORIES)[number]["value"]; - -export default function BugReportPage() { - const { isAuthenticated } = useAuth(); - const { t, locale } = useTranslations(); - const [category, setCategory] = useState("BUG"); - const [subject, setSubject] = useState(""); - const [description, setDescription] = useState(""); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState<{ - type: "success" | "error"; - message: string; - } | null>(null); - - // Status is now informational only — submission always works. - const { data: status } = useQuery({ - queryKey: queryKeys.bugreportStatus(), - queryFn: async () => { - return apiGet("/api/bugreport/status"); - }, - enabled: isAuthenticated, - }); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setResult(null); - setLoading(true); - - try { - await apiPost("/api/feedback", { - category, - subject: subject || description.slice(0, 60), - description, - metadata: { - locale, - userAgent: - typeof navigator !== "undefined" ? navigator.userAgent : null, - url: typeof window !== "undefined" ? window.location.pathname : null, - }, - }); - setResult({ type: "success", message: t("bugreport.success") }); - setSubject(""); - setDescription(""); - } catch (err) { - setResult({ - type: "error", - message: - err instanceof ApiError - ? err.message || t("bugreport.errorCreating") - : t("common.networkError"), - }); - } finally { - setLoading(false); - } - } - - if (!isAuthenticated) { - return ( -
-

- {t("bugreport.title")} -

-

- {t("bugreport.loginRequired")} -

-
- ); - } - - if (status && status.enabled === false) { - return ( -
-
-

- {t("bugreport.title")} -

-

- {t("bugreport.subtitle")} -

-
-
- -
-

- {t("bugreport.disabledTitle")} -

-

- {t("bugreport.disabledBody")} -

-
-
-
- ); - } - - return ( -
-
-

- {t("bugreport.title")} -

-

- {t("bugreport.subtitle")} -

-
- - {status?.configured && ( -
- -

- {t("bugreport.githubEscalationNote")} -

-
- )} - {!status?.configured && ( -
- -

- {t("bugreport.internalOnlyNote")} -

-
- )} - -
-
-
-
- - -
-
- - setSubject(e.target.value)} - placeholder={t("bugreport.bugTitlePlaceholder")} - maxLength={200} - /> -
-
- -
- -