diff --git a/CHANGELOG.md b/CHANGELOG.md index 5764c2975..d1b937773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ ## [Unreleased] +## [1.23.0] — 2026-06-27 — Account security and data sovereignty + +A security release. It adds two-factor authentication, brings every sign-in and sensitive action under a stronger check, encrypts free-text health notes at rest, lets you download an encrypted copy of your record, and gathers export, deletion, and privacy controls into one place. Seven additive migrations (`0199`–`0205`) apply automatically on start; `0205` removes the retired bug-report tables. + +### Added + +- Two-factor authentication: an authenticator app (TOTP) or a hardware security key as a second factor, with one-time recovery codes — set up under Settings → Security. +- Sign-in asks for the second factor when it is enabled; you can trust a device for 30 days to skip it, while the password is always still required. +- Passkeys now show when they were last used and can be renamed, with a gentle prompt to add one. +- An active-session list with sign-out-everywhere, and an alert when a new device signs in. +- A security activity log of recent sign-ins, second-factor changes, and exports. +- A passphrase-encrypted export, so a downloaded copy of your record is never plain text — keep the passphrase safe, there is no recovery. +- A Data & Privacy page that gathers export, deletion, retention, encryption status, sessions, and activity in one place. +- A check that warns when a chosen password has appeared in a known breach. +- Admin controls to require a second factor and to review encryption coverage and key-rotation progress. + +### Changed + +- Free-text notes on measurements and mood entries are now encrypted at rest. +- Sensitive actions — account deletion, data reset, encrypted export, disabling the second factor, and key rotation — require a fresh second-factor check. + +### Removed + +- The bug-report integration has been removed. + +### Security + +- A second-factor session is bound to the credential it was issued for; an API token can never satisfy a fresh-factor check or reach the admin surface. + ## [1.22.1] — 2026-06-27 — Mobile and Coach polish A reliability and quality-of-life patch focused on the mobile interface and the Coach. 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/docs/api/openapi.yaml b/docs/api/openapi.yaml index 79e597122..43653028d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.22.1 + version: 1.23.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1349,13 +1349,49 @@ 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: - 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 +1410,263 @@ 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 + /api/auth/me/mfa: + get: + tags: + - Auth + summary: Second-factor status (cookie session only) + description: Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata + only — no secret, code, or public key. Cookie-only. + responses: + "200": + description: Second-factor status. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaStatusEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/webauthn/register/options: + post: + tags: + - Auth + summary: Begin registering a security key as a second factor + description: Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA. + responses: + "200": + description: Registration options issued. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/webauthn/register/verify: + post: + tags: + - Auth + summary: Finish registering a security key as a second factor + description: Verifies the attestation against the user-bound challenge and stores the credential in the second-factor + store (separate from primary passkeys). Cookie-only. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MfaWebauthnRegisterVerifyRequest" + responses: + "200": + description: Security key registered. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaWebauthnRegisterVerifyEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/webauthn/{id}: + patch: + tags: + - Auth + summary: Rename a registered security key + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MfaWebauthnRenameRequest" + responses: + "200": + description: Security key renamed. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaWebauthnRenameEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + delete: + tags: + - Auth + summary: Remove a registered security key (step-up gated) + description: Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate. + responses: + "200": + description: Security key removed. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaWebauthnRemoveEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/mfa/webauthn/verify/options: + post: + tags: + - Auth + summary: Begin a mid-login security-key assertion + description: Presents the login `mfaTicket` and returns assertion options scoped to the password-identified user's + registered security keys. Anonymous surface; rate-limited. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MfaWebauthnLoginOptionsRequest" + responses: + "200": + description: Assertion options issued. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaWebauthnLoginOptionsEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/mfa/webauthn/verify: + post: + tags: + - Auth + summary: Complete a security-key second-factor login challenge + description: Presents the login `mfaTicket` plus the assertion. On success returns the SAME token bundle / session the + password path issues, with the session marked second-factor-verified. The ticket is single-use; failures are + throttled and the ticket is burned at the attempt cap. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MfaWebauthnLoginVerifyRequest" + responses: + "200": + description: Second factor verified — session + optional bearer issued. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 "422": *a3 "429": *a4 @@ -1434,6 +1722,135 @@ 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/trusted-devices: + get: + tags: + - Auth + summary: List trusted devices + description: v1.23 — the 'remember this device' list. A trusted device skips the second factor for 30 days (the password + is still required). Returns only an IP-free device label + lifecycle timestamps, never the token. + responses: + "200": + description: Trusted devices for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/TrustedDeviceListEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + delete: + tags: + - Auth + summary: Forget every trusted device + description: v1.23 — revokes all trusted devices for the caller and clears the caller's own trusted-device cookie. + responses: + "200": + description: All trusted devices revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/TrustedDeviceRevokeAllEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/trusted-devices/{id}: + delete: + tags: + - Auth + summary: Revoke a single trusted device + description: v1.23 — revokes one trusted device by id, scoped to the authenticated user (a foreign id returns 404). + responses: + "200": + description: Trusted device revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/TrustedDeviceRevokeEnvelope" + "401": *a1 + "404": + description: Trusted device 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: @@ -7492,6 +7909,171 @@ 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: + 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 + rememberDevice: + description: Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is + still required). + type: boolean + 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. + MfaWebauthnRegisterVerifyRequest: + type: object + properties: + challengeId: + type: string + minLength: 1 + credential: + type: object + properties: + id: + type: string + rawId: + type: string + type: + type: string + const: public-key + response: + type: object + propertyNames: + type: string + additionalProperties: {} + authenticatorAttachment: + type: string + enum: + - platform + - cross-platform + clientExtensionResults: + type: object + propertyNames: + type: string + additionalProperties: {} + required: + - id + - rawId + - type + - response + description: SimpleWebAuthn ceremony response payload. + name: + type: string + minLength: 1 + maxLength: 64 + required: + - challengeId + - credential + description: Finish registering a WebAuthn security key as a second factor. + MfaWebauthnRenameRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 64 + required: + - name + MfaWebauthnLoginOptionsRequest: + type: object + properties: + mfaTicket: + type: string + minLength: 1 + description: The opaque single-use ticket from the login response. + required: + - mfaTicket + MfaWebauthnLoginVerifyRequest: + type: object + properties: + mfaTicket: + type: string + minLength: 1 + challengeId: + type: string + minLength: 1 + credential: + type: object + properties: + id: + type: string + rawId: + type: string + type: + type: string + const: public-key + response: + type: object + propertyNames: + type: string + additionalProperties: {} + authenticatorAttachment: + type: string + enum: + - platform + - cross-platform + clientExtensionResults: + type: object + propertyNames: + type: string + additionalProperties: {} + required: + - id + - rawId + - type + - response + description: SimpleWebAuthn ceremony response payload. + rememberDevice: + description: Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is + still required). + type: boolean + required: + - mfaTicket + - challengeId + - credential + description: Complete a second-factor login challenge with a WebAuthn security key and receive the same token bundle the + password path issues. PasskeyLoginVerifyRequest: type: object properties: @@ -10660,6 +11242,361 @@ 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 + - webauthn + description: Second factors the account can complete the challenge with. `webauthn` is completed via + /api/auth/mfa/webauthn/verify; the rest via /api/auth/mfa/verify. + 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 + MfaStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MfaStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MfaStatusResponse: + type: object + properties: + totp: + type: object + properties: + enabled: + type: boolean + required: + - enabled + additionalProperties: false + recoveryCodesRemaining: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + webauthn: + type: array + items: + $ref: "#/components/schemas/WebauthnMfaCredentialInfo" + passkeyNudgeDismissed: + type: boolean + required: + - totp + - recoveryCodesRemaining + - webauthn + - passkeyNudgeDismissed + additionalProperties: false + WebauthnMfaCredentialInfo: + type: object + properties: + id: + type: string + name: + type: string + 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)))$ + lastUsedAt: + 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" + required: + - id + - name + - createdAt + - lastUsedAt + additionalProperties: false + MfaWebauthnRegisterOptionsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WebauthnOptionsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WebauthnOptionsResponse: + type: object + properties: + options: + type: object + propertyNames: + type: string + additionalProperties: {} + description: SimpleWebAuthn options to pass to the browser ceremony. + challengeId: + type: string + description: Server-issued challenge id. + required: + - options + - challengeId + additionalProperties: false + MfaWebauthnRegisterVerifyEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WebauthnMfaCredentialInfo" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MfaWebauthnRenameEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WebauthnMfaCredentialInfo" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MfaWebauthnRemoveEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AuthSuccessFlagResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AuthSuccessFlagResponse: + type: object + properties: + success: + type: boolean + required: + - success + additionalProperties: false + MfaWebauthnLoginOptionsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WebauthnOptionsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MfaWebauthnVerifyResponse: + 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 PasskeyLoginVerifyResponse: type: object properties: @@ -10694,6 +11631,283 @@ 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 + TrustedDeviceListEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/TrustedDeviceListResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TrustedDeviceListResponse: + type: object + properties: + devices: + type: array + items: + type: object + properties: + id: + type: string + label: + anyOf: + - type: string + - type: "null" + description: Coarse, IP-free device label (e.g. 'Firefox on macOS'). + 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)))$ + lastUsedAt: + 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)))$ + expiresAt: + 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 device making this request. + required: + - id + - label + - createdAt + - lastUsedAt + - expiresAt + - isCurrent + additionalProperties: false + required: + - devices + additionalProperties: false + TrustedDeviceRevokeAllEnvelope: + type: object + properties: + data: + type: object + properties: + revoked: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TrustedDeviceRevokeEnvelope: + 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/docs/ops/mfa-enforcement.md b/docs/ops/mfa-enforcement.md new file mode 100644 index 000000000..2118fe56a --- /dev/null +++ b/docs/ops/mfa-enforcement.md @@ -0,0 +1,34 @@ +# Require a second factor (operator) + +The admin settings expose a **Require a second factor** policy — instance-wide +under General settings, and as a per-user override on the user detail page. The +effective requirement for an account is the OR of the per-user override and the +instance-wide policy. + +## What enforcement does — and does not — do + +Enforcement is a **guidance nudge for web sessions, not a hard API wall.** + +- When the policy is on, any account that signs in **on the web** without an + active second factor is redirected to enrollment after authentication. It + cannot reach the rest of the app until it enrolls. +- Enforcement does **not** block direct API or native (Bearer-token) access for + an account that has not yet enrolled. A token-authenticated client keeps + working until the user enrolls a factor of their own accord. + +This is deliberate. A hard API wall on a not-yet-enrolled account would lock a +native client (or an automation using a long-lived token) out of its own data +with no in-band way to enrol, and there is no server-side path to mint a second +factor on the user's behalf. The step-up gates that protect destructive actions +(MFA disable, recovery-code regeneration, account deletion, encrypted export) +remain cookie-only and are unaffected by this policy — they apply to accounts +that have actually enrolled a factor. + +## Operator guidance + +- Treat the policy as a strong prompt that drives adoption, not as a control + that retroactively secures every transport. +- To hard-gate a specific account's API access, the lever is the account + itself (disable it, or rotate/revoke its tokens), not the MFA-required flag. +- The redirect clears the moment the user completes enrollment; no operator + action is needed afterwards. diff --git a/messages/de.json b/messages/de.json index 523158b31..88c0d24d1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -32,10 +32,10 @@ "copy": "Kopieren", "copyDetails": "Details kopieren", "retry": "Erneut versuchen", - "reportIssue": "Fehler melden", "showPassword": "Passwort anzeigen", "hidePassword": "Passwort verbergen", - "learnMore": "Mehr erfahren" + "learnMore": "Mehr erfahren", + "dismiss": "Schließen" }, "doctorReport": { "title": "Gesundheitsbericht", @@ -204,7 +204,6 @@ "insights": "Insights", "achievements": "Erfolge", "settings": "Einstellungen", - "bugreport": "Bugreport", "admin": "Admin-Konsole", "logout": "Abmelden", "login": "Anmelden", @@ -263,7 +262,28 @@ "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.", + "mfa": { + "title": "Zwei-Faktor-Authentifizierung", + "totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.", + "recoveryHint": "Gib einen deiner gespeicherten Wiederherstellungscodes ein.", + "securityKeyHint": "Verwende deinen registrierten Sicherheitsschlüssel, um fortzufahren.", + "codeLabel": "Authentifizierungscode", + "recoveryLabel": "Wiederherstellungscode", + "verify": "Bestätigen", + "verifyFailed": "Bestätigung fehlgeschlagen. Bitte versuche es erneut.", + "useSecurityKey": "Sicherheitsschlüssel verwenden", + "useRecoveryCode": "Wiederherstellungscode verwenden", + "useAuthenticator": "Authenticator-App verwenden", + "backToLogin": "Zurück zur Anmeldung", + "rememberDevice": "Diesem Gerät 30 Tage vertrauen" + }, + "enrollMfa": { + "title": "Zweiten Faktor einrichten", + "body": "Dein Administrator verlangt einen zweiten Faktor für dieses Konto. Füge eine Authenticator-App oder einen Sicherheitsschlüssel hinzu, um fortzufahren.", + "signOut": "Abmelden" + } }, "dashboard": { "title": "Dashboard", @@ -4057,7 +4077,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", @@ -4197,6 +4222,15 @@ "title": "Kanäle", "description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail.", "subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden." + }, + "security": { + "title": "Sicherheit", + "subtitle": "Zwei-Faktor-Authentifizierung, Wiederherstellungscodes und Passkeys." + }, + "privacy": { + "title": "Daten & Datenschutz", + "subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort.", + "description": "Export, Löschung, Aufbewahrung, Verschlüsselungsstatus, Sitzungen und Sicherheitsaktivität." } }, "profile": "Profil", @@ -4968,7 +5002,153 @@ "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" + }, + "active": "Aktiv", + "inactive": "Inaktiv", + "stepUpRequired": "Melde dich aus Sicherheitsgründen erneut an, um deinen zweiten Faktor zu ändern.", + "useRecoveryCode": "Stattdessen Wiederherstellungscode verwenden", + "useAuthenticator": "Stattdessen Authenticator-App verwenden", + "totp": { + "title": "Authenticator-App", + "description": "Verwende einen zeitbasierten Code aus einer Authenticator-App als zweiten Faktor.", + "setUp": "Einrichten", + "step1": "Scanne den QR-Code mit deiner Authenticator-App oder gib das Geheimnis manuell ein.", + "step2": "Gib zur Bestätigung den 6-stelligen Code aus der App ein.", + "qrAlt": "QR-Code zur Einrichtung der Authenticator-App", + "qrUnavailable": "QR-Code nicht verfügbar", + "manualEntry": "Oder gib dieses Geheimnis manuell ein", + "enterCode": "Gib den 6-stelligen Code ein", + "verify": "Bestätigen", + "setupFailed": "Einrichtung konnte nicht gestartet werden. Bitte versuche es erneut.", + "invalidCode": "Dieser Code ist ungültig. Bitte versuche es erneut.", + "disable": "Deaktivieren", + "disableTitle": "Zwei-Faktor-Authentifizierung deaktivieren", + "disableConfirm": "Gib einen aktuellen Code ein, um deinen zweiten Faktor abzuschalten. Damit werden auch deine Wiederherstellungscodes gelöscht.", + "currentCode": "Aktueller Code", + "disableFailed": "Der zweite Faktor konnte nicht deaktiviert werden." + }, + "recovery": { + "saveNow": "Speichere diese Wiederherstellungscodes jetzt", + "saveNowHint": "Jeder Code funktioniert einmal. Sie werden nur dieses eine Mal angezeigt — bewahre sie sicher auf. Wenn du deine Authenticator-App verlierst, ist ein Wiederherstellungscode der einzige Weg zurück.", + "download": "Herunterladen", + "remainingOne": "{count} Wiederherstellungscode übrig.", + "remainingOther": "{count} Wiederherstellungscodes übrig.", + "regenerate": "Codes neu erzeugen", + "regenFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden.", + "savedIt": "Ich habe sie gespeichert", + "regenerateTitle": "Wiederherstellungscodes neu erzeugen?", + "regenerateConfirm": "Damit werden deine aktuellen Wiederherstellungscodes ersetzt. Deine bisherigen Codes funktionieren sofort nicht mehr, und der neue Satz wird nur einmal angezeigt." + }, + "keys": { + "title": "Sicherheitsschlüssel", + "description": "Registriere einen Hardware- oder Plattform-Sicherheitsschlüssel als zweiten Faktor.", + "empty": "Noch keine Sicherheitsschlüssel registriert.", + "add": "Sicherheitsschlüssel hinzufügen", + "addFailed": "Der Sicherheitsschlüssel konnte nicht registriert werden.", + "rename": "Umbenennen", + "renameFailed": "Der Sicherheitsschlüssel konnte nicht umbenannt werden.", + "remove": "Sicherheitsschlüssel entfernen", + "removeFailed": "Der Sicherheitsschlüssel konnte nicht entfernt werden.", + "removeConfirm": "Dieser Sicherheitsschlüssel funktioniert dann nicht mehr als zweiter Faktor.", + "lastUsed": "Zuletzt verwendet {date}", + "neverUsed": "Noch nie verwendet", + "added": "hinzugefügt {date}", + "nameLabel": "Name des Sicherheitsschlüssels" + }, + "passkeyNudge": { + "title": "Füge einen Passkey für schnellere Anmeldung hinzu", + "body": "Mit Passkeys meldest du dich per Fingerabdruck, Gesicht oder Geräte-PIN an — ohne Passwort zum Merken." + }, + "trustedDevices": { + "title": "Vertrauenswürdige Geräte", + "description": "Browser, denen du vertraust, überspringen den zweiten Faktor für 30 Tage. Das Passwort wird weiterhin verlangt. Entziehe das Vertrauen, wenn du ein Gerät nicht kennst.", + "loadError": "Vertrauenswürdige Geräte konnten nicht geladen werden.", + "revokeError": "Gerät konnte nicht entfernt werden. Bitte erneut versuchen.", + "revoke": "Entfernen", + "revokeAll": "Alle Geräte vergessen", + "revokeAllDone": "{count} vertrauenswürdige(s) Gerät(e) entfernt.", + "unnamed": "Unbekanntes Gerät", + "current": "Dieses Gerät", + "expires": "Vertraut bis {date}" + } + }, + "privacy": { + "intro": { + "title": "Deine Daten bleiben deine", + "body": "HealthLog wird selbst gehostet. Diese Seite fasst zusammen, wie deine Daten geschützt werden, wie lange sie gespeichert bleiben, wie du sie exportierst und wie du sie löschst." + }, + "encryption": { + "title": "Verschlüsselt gespeichert", + "description": "Sensible Felder werden in der Datenbank verschlüsselt.", + "summary": "Deine sensiblen Daten werden mit {algorithm} verschlüsselt gespeichert – über {columns} Felder in {models} Datensatztypen.", + "statement": "Deine sensiblen Daten werden mit AES-256-GCM verschlüsselt gespeichert." + }, + "stored": { + "title": "Was gespeichert wird", + "description": "Die Datenkategorien, die dieses Konto enthält.", + "measurements": "Messwerte (Gewicht, Blutdruck, Puls, Glukose, Schlaf, Körperzusammensetzung)", + "medications": "Medikamente und Einnahmeverlauf", + "moodLabs": "Stimmungseinträge und Laborwerte", + "coach": "Coach-Unterhaltungen", + "integrations": "Integrations-Tokens (verschlüsselt)", + "security": "Konto- und Sicherheitsaktivität (Sitzungen, Anmeldeprotokoll)" + }, + "retention": { + "title": "Aufbewahrung", + "description": "Wie lange zeitgebundene Datensätze vor der automatischen Bereinigung gespeichert bleiben.", + "coach": "Coach-Unterhaltungen: {days} Tage", + "audit": "Sicherheitsprotokoll: {days} Tage", + "delivery": "Benachrichtigungsprotokoll: {days} Tage", + "unavailable": "Aufbewahrungsdetails sind derzeit nicht verfügbar." + }, + "export": { + "title": "Meine Daten herunterladen", + "description": "Exportiere deine Datensätze als CSV oder JSON, optional passphrasen-verschlüsselt, oder erstelle eine vollständige Gesundheitsakte.", + "openExport": "Export & Backup", + "openHealthRecord": "Gesundheitsakte" + }, + "delete": { + "title": "Löschen oder zurücksetzen", + "description": "Lösche dein Konto vollständig oder lösche deine Gesundheitsdaten und behalte das Konto.", + "backupLag": "Die Löschung entfernt deine Daten sofort. Verschlüsselte Off-Host-Backups enthalten sie weiterhin, bis sie aus dem Aufbewahrungsfenster des Betreibers herausfallen.", + "openAdvanced": "Löschen oder zurücksetzen" + }, + "posture": { + "title": "Datenschutz-Einstellung", + "description": "Steuere, wie viele deiner Daten an KI-Funktionen und die freiwillige Forschung weitergegeben werden.", + "openAi": "KI-Datenschutzmodus", + "openResearch": "Forschungsmodus" + } + } }, "admin": { "title": "Administration", @@ -5064,7 +5244,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 +5280,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 +5313,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 +5466,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 +5518,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." @@ -5521,10 +5645,55 @@ "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", - "carrierUnavailableGeoFallback": "{location} — Anbieter nicht verfügbar" + "carrierUnavailableGeoFallback": "{location} — Anbieter nicht verfügbar", + "mfaRequired": "Zweiten Faktor verlangen", + "mfaRequiredDescription": "Jedes Konto ohne aktiven zweiten Faktor wird nach der Anmeldung zur Einrichtung geleitet. Das fordert Web-Nutzer zur Einrichtung auf – es blockiert jedoch keinen direkten API- oder nativen (Bearer-)Zugriff für noch nicht eingerichtete Konten.", + "mfaEnforcedOn": "MFA an", + "mfaEnforcedOff": "MFA aus", + "mfaEnforcedOnHint": "Für diesen Nutzer ist ein zweiter Faktor erforderlich. Klicken, um die Anforderung aufzuheben.", + "mfaEnforcedOffHint": "Einen zweiten Faktor für diesen Nutzer verlangen." }, "achievements": { "title": "Erfolge", @@ -5583,10 +5752,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 +5940,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 +5992,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 +6096,13 @@ "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" + }, + "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten." }, "i18n": { "maintainershipBanner": { diff --git a/messages/en.json b/messages/en.json index 16ec52b02..17b8c277d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -32,10 +32,10 @@ "copy": "Copy", "copyDetails": "Copy details", "retry": "Retry", - "reportIssue": "Report issue", "showPassword": "Show password", "hidePassword": "Hide password", - "learnMore": "Learn more" + "learnMore": "Learn more", + "dismiss": "Dismiss" }, "doctorReport": { "title": "Health Report", @@ -204,7 +204,6 @@ "insights": "Insights", "achievements": "Achievements", "settings": "Settings", - "bugreport": "Bug Report", "admin": "Admin Console", "logout": "Sign out", "login": "Sign in", @@ -263,7 +262,28 @@ "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.", + "mfa": { + "title": "Two-factor authentication", + "totpHint": "Enter the 6-digit code from your authenticator app.", + "recoveryHint": "Enter one of your saved recovery codes.", + "securityKeyHint": "Use your registered security key to continue.", + "codeLabel": "Authentication code", + "recoveryLabel": "Recovery code", + "verify": "Verify", + "verifyFailed": "Verification failed. Please try again.", + "useSecurityKey": "Use a security key", + "useRecoveryCode": "Use a recovery code", + "useAuthenticator": "Use your authenticator app", + "backToLogin": "Back to login", + "rememberDevice": "Trust this device for 30 days" + }, + "enrollMfa": { + "title": "Set up a second factor", + "body": "Your administrator requires a second factor on this account. Add an authenticator app or a security key to continue.", + "signOut": "Sign out" + } }, "dashboard": { "title": "Dashboard", @@ -4057,7 +4077,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", @@ -4197,6 +4222,15 @@ "title": "Channels", "description": "Telegram, ntfy, Web Push, Webhook, and Email delivery.", "subtitle": "Where reminders and alerts are delivered." + }, + "security": { + "title": "Security", + "subtitle": "Two-factor authentication, recovery codes, and passkeys." + }, + "privacy": { + "title": "Data & Privacy", + "subtitle": "Your data, retention, encryption, and account security in one place.", + "description": "Export, deletion, retention, encryption status, sessions, and security activity." } }, "profile": "Profile", @@ -4968,7 +5002,153 @@ "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" + }, + "active": "Active", + "inactive": "Inactive", + "stepUpRequired": "For your security, sign in again to change your second factor.", + "useRecoveryCode": "Use a recovery code instead", + "useAuthenticator": "Use your authenticator app instead", + "totp": { + "title": "Authenticator app", + "description": "Use a time-based code from an authenticator app as your second factor.", + "setUp": "Set up", + "step1": "Scan the QR code with your authenticator app, or enter the secret manually.", + "step2": "Enter the 6-digit code the app shows to confirm.", + "qrAlt": "QR code for authenticator app setup", + "qrUnavailable": "QR code unavailable", + "manualEntry": "Or enter this secret manually", + "enterCode": "Enter the 6-digit code", + "verify": "Verify", + "setupFailed": "Could not start setup. Please try again.", + "invalidCode": "That code is not valid. Please try again.", + "disable": "Disable", + "disableTitle": "Disable two-factor authentication", + "disableConfirm": "Enter a current code to turn off your second factor. This also deletes your recovery codes.", + "currentCode": "Current code", + "disableFailed": "Could not disable the second factor." + }, + "recovery": { + "saveNow": "Save these recovery codes now", + "saveNowHint": "Each code works once. They are shown only this time — store them somewhere safe. If you lose your authenticator, a recovery code is the only way back in.", + "download": "Download", + "remainingOne": "{count} recovery code remaining.", + "remainingOther": "{count} recovery codes remaining.", + "regenerate": "Regenerate codes", + "regenFailed": "Could not regenerate recovery codes.", + "savedIt": "I've saved them", + "regenerateTitle": "Regenerate recovery codes?", + "regenerateConfirm": "This replaces your current recovery codes. Your existing codes will stop working immediately, and a new set is shown only once." + }, + "keys": { + "title": "Security keys", + "description": "Register a hardware or platform security key as a second factor.", + "empty": "No security keys registered yet.", + "add": "Add security key", + "addFailed": "Could not register the security key.", + "rename": "Rename", + "renameFailed": "Could not rename the security key.", + "remove": "Remove security key", + "removeFailed": "Could not remove the security key.", + "removeConfirm": "This security key will no longer work as a second factor.", + "lastUsed": "Last used {date}", + "neverUsed": "Never used", + "added": "added {date}", + "nameLabel": "Security key name" + }, + "passkeyNudge": { + "title": "Add a passkey for faster sign-in", + "body": "Passkeys let you sign in with your fingerprint, face, or device PIN — no password to remember." + }, + "trustedDevices": { + "title": "Trusted devices", + "description": "Browsers you chose to trust skip the second factor for 30 days. The password is still required. Revoke any you don't recognize.", + "loadError": "Could not load trusted devices.", + "revokeError": "Could not revoke the device. Please try again.", + "revoke": "Revoke", + "revokeAll": "Forget all devices", + "revokeAllDone": "Forgot {count} trusted device(s).", + "unnamed": "Unknown device", + "current": "This device", + "expires": "Trusted until {date}" + } + }, + "privacy": { + "intro": { + "title": "Your data stays yours", + "body": "HealthLog is self-hosted. This page brings together how your data is protected, how long it is kept, how to get it out, and how to delete it." + }, + "encryption": { + "title": "Encrypted at rest", + "description": "Sensitive fields are encrypted in the database.", + "summary": "Your sensitive data is encrypted at rest with {algorithm} across {columns} fields in {models} record types.", + "statement": "Your sensitive data is encrypted at rest with AES-256-GCM." + }, + "stored": { + "title": "What's stored", + "description": "The categories of data this account holds.", + "measurements": "Measurements (weight, blood pressure, pulse, glucose, sleep, body composition)", + "medications": "Medications and intake history", + "moodLabs": "Mood entries and lab results", + "coach": "Coach conversations", + "integrations": "Integration tokens (encrypted)", + "security": "Account and security activity (sessions, sign-in log)" + }, + "retention": { + "title": "Retention", + "description": "How long time-bound records are kept before automatic cleanup.", + "coach": "Coach conversations: kept {days} days", + "audit": "Security activity log: kept {days} days", + "delivery": "Notification delivery log: kept {days} days", + "unavailable": "Retention details are unavailable right now." + }, + "export": { + "title": "Download my data", + "description": "Export your records as CSV or JSON, optionally passphrase-encrypted, or generate a full health record.", + "openExport": "Export & backup", + "openHealthRecord": "Health record" + }, + "delete": { + "title": "Delete or reset", + "description": "Remove your account entirely, or wipe your health records while keeping the account.", + "backupLag": "Deletion removes your data immediately. Encrypted off-host backups still contain it until they roll off the operator's backup retention window.", + "openAdvanced": "Delete or reset" + }, + "posture": { + "title": "Privacy posture", + "description": "Control how much your data is shared with AI features and opt-in research.", + "openAi": "AI privacy mode", + "openResearch": "Research mode" + } + } }, "admin": { "title": "Administration", @@ -5064,7 +5244,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 +5280,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 +5313,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 +5466,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 +5518,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." @@ -5521,10 +5645,55 @@ "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", - "carrierUnavailableGeoFallback": "{location} — carrier unavailable" + "carrierUnavailableGeoFallback": "{location} — carrier unavailable", + "mfaRequired": "Require a second factor", + "mfaRequiredDescription": "Every account without an active second factor is sent to enrollment after sign-in. This prompts web users to enroll — it does not block direct API or native (Bearer) access for accounts not yet enrolled.", + "mfaEnforcedOn": "MFA on", + "mfaEnforcedOff": "MFA off", + "mfaEnforcedOnHint": "A second factor is required for this user. Click to stop requiring it.", + "mfaEnforcedOffHint": "Require a second factor for this user." }, "achievements": { "title": "Achievements", @@ -5583,10 +5752,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 +5940,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 +5992,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 +6096,13 @@ "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" + }, + "loginRequired": "Please sign in to manage notifications." }, "i18n": { "maintainershipBanner": { diff --git a/messages/es.json b/messages/es.json index 8bea74277..1b65e0e68 100644 --- a/messages/es.json +++ b/messages/es.json @@ -32,10 +32,10 @@ "copy": "Copiar", "copyDetails": "Copiar detalles", "retry": "Reintentar", - "reportIssue": "Reportar problema", "showPassword": "Mostrar contraseña", "hidePassword": "Ocultar contraseña", - "learnMore": "Más información" + "learnMore": "Más información", + "dismiss": "Descartar" }, "doctorReport": { "title": "Informe de salud", @@ -204,7 +204,6 @@ "insights": "Insights", "achievements": "Logros", "settings": "Ajustes", - "bugreport": "Reporte de bug", "admin": "Consola de administración", "logout": "Cerrar sesión", "login": "Iniciar sesión", @@ -263,7 +262,28 @@ "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.", + "mfa": { + "title": "Autenticación de dos factores", + "totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.", + "recoveryHint": "Introduce uno de tus códigos de recuperación guardados.", + "securityKeyHint": "Usa tu llave de seguridad registrada para continuar.", + "codeLabel": "Código de autenticación", + "recoveryLabel": "Código de recuperación", + "verify": "Verificar", + "verifyFailed": "La verificación falló. Inténtalo de nuevo.", + "useSecurityKey": "Usar una llave de seguridad", + "useRecoveryCode": "Usar un código de recuperación", + "useAuthenticator": "Usar tu aplicación de autenticación", + "backToLogin": "Volver al inicio de sesión", + "rememberDevice": "Confiar en este dispositivo durante 30 días" + }, + "enrollMfa": { + "title": "Configurar un segundo factor", + "body": "Tu administrador exige un segundo factor en esta cuenta. Añade una app de autenticación o una llave de seguridad para continuar.", + "signOut": "Cerrar sesión" + } }, "dashboard": { "title": "Panel", @@ -4057,7 +4077,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", @@ -4197,6 +4222,15 @@ "title": "Canales", "description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico.", "subtitle": "Dónde se entregan los recordatorios y avisos." + }, + "security": { + "title": "Seguridad", + "subtitle": "Autenticación de dos factores, códigos de recuperación y claves de acceso." + }, + "privacy": { + "title": "Datos y privacidad", + "subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar.", + "description": "Exportación, eliminación, retención, estado de cifrado, sesiones y actividad de seguridad." } }, "profile": "Perfil", @@ -4968,7 +5002,153 @@ "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" + }, + "active": "Activo", + "inactive": "Inactivo", + "stepUpRequired": "Por tu seguridad, vuelve a iniciar sesión para cambiar tu segundo factor.", + "useRecoveryCode": "Usar un código de recuperación en su lugar", + "useAuthenticator": "Usar tu aplicación de autenticación en su lugar", + "totp": { + "title": "Aplicación de autenticación", + "description": "Usa un código temporal de una aplicación de autenticación como segundo factor.", + "setUp": "Configurar", + "step1": "Escanea el código QR con tu aplicación de autenticación o introduce el secreto manualmente.", + "step2": "Introduce el código de 6 dígitos que muestra la aplicación para confirmar.", + "qrAlt": "Código QR para configurar la aplicación de autenticación", + "qrUnavailable": "Código QR no disponible", + "manualEntry": "O introduce este secreto manualmente", + "enterCode": "Introduce el código de 6 dígitos", + "verify": "Verificar", + "setupFailed": "No se pudo iniciar la configuración. Inténtalo de nuevo.", + "invalidCode": "Ese código no es válido. Inténtalo de nuevo.", + "disable": "Desactivar", + "disableTitle": "Desactivar la autenticación de dos factores", + "disableConfirm": "Introduce un código actual para desactivar tu segundo factor. Esto también elimina tus códigos de recuperación.", + "currentCode": "Código actual", + "disableFailed": "No se pudo desactivar el segundo factor." + }, + "recovery": { + "saveNow": "Guarda ahora estos códigos de recuperación", + "saveNowHint": "Cada código funciona una sola vez. Solo se muestran esta vez: guárdalos en un lugar seguro. Si pierdes tu autenticador, un código de recuperación es la única forma de volver a entrar.", + "download": "Descargar", + "remainingOne": "Queda {count} código de recuperación.", + "remainingOther": "Quedan {count} códigos de recuperación.", + "regenerate": "Regenerar códigos", + "regenFailed": "No se pudieron regenerar los códigos de recuperación.", + "savedIt": "Los he guardado", + "regenerateTitle": "¿Regenerar los códigos de recuperación?", + "regenerateConfirm": "Esto reemplaza tus códigos de recuperación actuales. Tus códigos existentes dejarán de funcionar de inmediato y el nuevo conjunto se muestra solo una vez." + }, + "keys": { + "title": "Llaves de seguridad", + "description": "Registra una llave de seguridad de hardware o de plataforma como segundo factor.", + "empty": "Aún no hay llaves de seguridad registradas.", + "add": "Añadir llave de seguridad", + "addFailed": "No se pudo registrar la llave de seguridad.", + "rename": "Cambiar nombre", + "renameFailed": "No se pudo cambiar el nombre de la llave de seguridad.", + "remove": "Quitar llave de seguridad", + "removeFailed": "No se pudo quitar la llave de seguridad.", + "removeConfirm": "Esta llave de seguridad dejará de funcionar como segundo factor.", + "lastUsed": "Usada por última vez el {date}", + "neverUsed": "Nunca usada", + "added": "añadida el {date}", + "nameLabel": "Nombre de la llave de seguridad" + }, + "passkeyNudge": { + "title": "Añade una clave de acceso para iniciar sesión más rápido", + "body": "Las claves de acceso te permiten iniciar sesión con tu huella, tu cara o el PIN del dispositivo, sin contraseñas que recordar." + }, + "trustedDevices": { + "title": "Dispositivos de confianza", + "description": "Los navegadores en los que confías omiten el segundo factor durante 30 días. La contraseña sigue siendo obligatoria. Revoca los que no reconozcas.", + "loadError": "No se pudieron cargar los dispositivos de confianza.", + "revokeError": "No se pudo revocar el dispositivo. Inténtalo de nuevo.", + "revoke": "Revocar", + "revokeAll": "Olvidar todos los dispositivos", + "revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza.", + "unnamed": "Dispositivo desconocido", + "current": "Este dispositivo", + "expires": "De confianza hasta el {date}" + } + }, + "privacy": { + "intro": { + "title": "Tus datos siguen siendo tuyos", + "body": "HealthLog es autoalojado. Esta página reúne cómo se protegen tus datos, cuánto tiempo se conservan, cómo exportarlos y cómo eliminarlos." + }, + "encryption": { + "title": "Cifrado en reposo", + "description": "Los campos sensibles se cifran en la base de datos.", + "summary": "Tus datos sensibles se cifran en reposo con {algorithm} en {columns} campos de {models} tipos de registro.", + "statement": "Tus datos sensibles se cifran en reposo con AES-256-GCM." + }, + "stored": { + "title": "Qué se almacena", + "description": "Las categorías de datos que contiene esta cuenta.", + "measurements": "Mediciones (peso, presión arterial, pulso, glucosa, sueño, composición corporal)", + "medications": "Medicamentos e historial de tomas", + "moodLabs": "Entradas de estado de ánimo y resultados de laboratorio", + "coach": "Conversaciones con el asistente", + "integrations": "Tokens de integración (cifrados)", + "security": "Actividad de la cuenta y de seguridad (sesiones, registro de inicio de sesión)" + }, + "retention": { + "title": "Retención", + "description": "Cuánto tiempo se conservan los registros con límite temporal antes de su limpieza automática.", + "coach": "Conversaciones con el asistente: {days} días", + "audit": "Registro de seguridad: {days} días", + "delivery": "Registro de envío de notificaciones: {days} días", + "unavailable": "Los detalles de retención no están disponibles en este momento." + }, + "export": { + "title": "Descargar mis datos", + "description": "Exporta tus registros como CSV o JSON, opcionalmente cifrados con frase de contraseña, o genera un historial médico completo.", + "openExport": "Exportar y copia de seguridad", + "openHealthRecord": "Historial médico" + }, + "delete": { + "title": "Eliminar o restablecer", + "description": "Elimina tu cuenta por completo o borra tus registros de salud conservando la cuenta.", + "backupLag": "La eliminación borra tus datos de inmediato. Las copias de seguridad cifradas externas aún los contienen hasta que caduquen según la ventana de retención del operador.", + "openAdvanced": "Eliminar o restablecer" + }, + "posture": { + "title": "Configuración de privacidad", + "description": "Controla cuántos de tus datos se comparten con las funciones de IA y la investigación opcional.", + "openAi": "Modo de privacidad de IA", + "openResearch": "Modo investigación" + } + } }, "admin": { "title": "Administration", @@ -5064,7 +5244,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 +5280,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 +5313,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 +5466,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 +5518,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." @@ -5521,10 +5645,55 @@ "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", - "carrierUnavailableGeoFallback": "{location} — operador no disponible" + "carrierUnavailableGeoFallback": "{location} — operador no disponible", + "mfaRequired": "Exigir un segundo factor", + "mfaRequiredDescription": "Cada cuenta sin un segundo factor activo se dirige a la configuración tras iniciar sesión. Esto pide a los usuarios web que lo configuren, pero no bloquea el acceso directo por API o nativo (Bearer) de las cuentas que aún no lo tienen.", + "mfaEnforcedOn": "MFA activado", + "mfaEnforcedOff": "MFA desactivado", + "mfaEnforcedOnHint": "Este usuario debe usar un segundo factor. Haz clic para dejar de exigirlo.", + "mfaEnforcedOffHint": "Exigir un segundo factor a este usuario." }, "achievements": { "title": "Logros", @@ -5583,10 +5752,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 +5940,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 +5992,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 +6096,13 @@ "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" + }, + "loginRequired": "Inicia sesión para gestionar las notificaciones." }, "i18n": { "maintainershipBanner": { diff --git a/messages/fr.json b/messages/fr.json index d943773ff..2f84d4c60 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -32,10 +32,10 @@ "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" + "learnMore": "En savoir plus", + "dismiss": "Ignorer" }, "doctorReport": { "title": "Rapport de santé", @@ -204,7 +204,6 @@ "insights": "Insights", "achievements": "Succès", "settings": "Paramètres", - "bugreport": "Rapport de bug", "admin": "Console d’administration", "logout": "Se déconnecter", "login": "Se connecter", @@ -263,7 +262,28 @@ "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.", + "mfa": { + "title": "Authentification à deux facteurs", + "totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.", + "recoveryHint": "Saisissez l'un de vos codes de récupération enregistrés.", + "securityKeyHint": "Utilisez votre clé de sécurité enregistrée pour continuer.", + "codeLabel": "Code d'authentification", + "recoveryLabel": "Code de récupération", + "verify": "Vérifier", + "verifyFailed": "Échec de la vérification. Veuillez réessayer.", + "useSecurityKey": "Utiliser une clé de sécurité", + "useRecoveryCode": "Utiliser un code de récupération", + "useAuthenticator": "Utiliser votre application d'authentification", + "backToLogin": "Retour à la connexion", + "rememberDevice": "Faire confiance à cet appareil pendant 30 jours" + }, + "enrollMfa": { + "title": "Configurer un second facteur", + "body": "Votre administrateur exige un second facteur sur ce compte. Ajoutez une application d'authentification ou une clé de sécurité pour continuer.", + "signOut": "Se déconnecter" + } }, "dashboard": { "title": "Tableau de bord", @@ -4057,7 +4077,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", @@ -4197,6 +4222,15 @@ "title": "Canaux", "description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail.", "subtitle": "Où sont envoyés les rappels et alertes." + }, + "security": { + "title": "Sécurité", + "subtitle": "Authentification à deux facteurs, codes de récupération et clés d'accès." + }, + "privacy": { + "title": "Données et confidentialité", + "subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit.", + "description": "Export, suppression, conservation, état du chiffrement, sessions et activité de sécurité." } }, "profile": "Profil", @@ -4968,7 +5002,153 @@ "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é" + }, + "active": "Actif", + "inactive": "Inactif", + "stepUpRequired": "Pour votre sécurité, reconnectez-vous pour modifier votre second facteur.", + "useRecoveryCode": "Utiliser plutôt un code de récupération", + "useAuthenticator": "Utiliser plutôt votre application d'authentification", + "totp": { + "title": "Application d'authentification", + "description": "Utilisez un code temporaire d'une application d'authentification comme second facteur.", + "setUp": "Configurer", + "step1": "Scannez le code QR avec votre application d'authentification, ou saisissez le secret manuellement.", + "step2": "Saisissez le code à 6 chiffres affiché par l'application pour confirmer.", + "qrAlt": "Code QR pour configurer l'application d'authentification", + "qrUnavailable": "Code QR indisponible", + "manualEntry": "Ou saisissez ce secret manuellement", + "enterCode": "Saisissez le code à 6 chiffres", + "verify": "Vérifier", + "setupFailed": "Impossible de démarrer la configuration. Veuillez réessayer.", + "invalidCode": "Ce code n'est pas valide. Veuillez réessayer.", + "disable": "Désactiver", + "disableTitle": "Désactiver l'authentification à deux facteurs", + "disableConfirm": "Saisissez un code actuel pour désactiver votre second facteur. Cela supprime aussi vos codes de récupération.", + "currentCode": "Code actuel", + "disableFailed": "Impossible de désactiver le second facteur." + }, + "recovery": { + "saveNow": "Enregistrez ces codes de récupération maintenant", + "saveNowHint": "Chaque code ne fonctionne qu'une fois. Ils ne sont affichés que cette fois — conservez-les en lieu sûr. Si vous perdez votre authentificateur, un code de récupération est le seul moyen de revenir.", + "download": "Télécharger", + "remainingOne": "{count} code de récupération restant.", + "remainingOther": "{count} codes de récupération restants.", + "regenerate": "Régénérer les codes", + "regenFailed": "Impossible de régénérer les codes de récupération.", + "savedIt": "Je les ai enregistrés", + "regenerateTitle": "Régénérer les codes de récupération ?", + "regenerateConfirm": "Cela remplace vos codes de récupération actuels. Vos codes existants cesseront de fonctionner immédiatement et le nouveau jeu n'est affiché qu'une seule fois." + }, + "keys": { + "title": "Clés de sécurité", + "description": "Enregistrez une clé de sécurité matérielle ou de plateforme comme second facteur.", + "empty": "Aucune clé de sécurité enregistrée pour le moment.", + "add": "Ajouter une clé de sécurité", + "addFailed": "Impossible d'enregistrer la clé de sécurité.", + "rename": "Renommer", + "renameFailed": "Impossible de renommer la clé de sécurité.", + "remove": "Supprimer la clé de sécurité", + "removeFailed": "Impossible de supprimer la clé de sécurité.", + "removeConfirm": "Cette clé de sécurité ne fonctionnera plus comme second facteur.", + "lastUsed": "Dernière utilisation le {date}", + "neverUsed": "Jamais utilisée", + "added": "ajoutée le {date}", + "nameLabel": "Nom de la clé de sécurité" + }, + "passkeyNudge": { + "title": "Ajoutez une clé d'accès pour une connexion plus rapide", + "body": "Les clés d'accès vous permettent de vous connecter avec votre empreinte, votre visage ou le code de votre appareil, sans mot de passe à retenir." + }, + "trustedDevices": { + "title": "Appareils de confiance", + "description": "Les navigateurs auxquels vous faites confiance ignorent le second facteur pendant 30 jours. Le mot de passe reste requis. Révoquez ceux que vous ne reconnaissez pas.", + "loadError": "Impossible de charger les appareils de confiance.", + "revokeError": "Impossible de révoquer l'appareil. Veuillez réessayer.", + "revoke": "Révoquer", + "revokeAll": "Oublier tous les appareils", + "revokeAllDone": "{count} appareil(s) de confiance oublié(s).", + "unnamed": "Appareil inconnu", + "current": "Cet appareil", + "expires": "De confiance jusqu'au {date}" + } + }, + "privacy": { + "intro": { + "title": "Vos données vous appartiennent", + "body": "HealthLog est auto-hébergé. Cette page réunit la façon dont vos données sont protégées, leur durée de conservation, comment les exporter et comment les supprimer." + }, + "encryption": { + "title": "Chiffré au repos", + "description": "Les champs sensibles sont chiffrés dans la base de données.", + "summary": "Vos données sensibles sont chiffrées au repos avec {algorithm} sur {columns} champs dans {models} types d'enregistrement.", + "statement": "Vos données sensibles sont chiffrées au repos avec AES-256-GCM." + }, + "stored": { + "title": "Ce qui est stocké", + "description": "Les catégories de données que contient ce compte.", + "measurements": "Mesures (poids, tension, pouls, glycémie, sommeil, composition corporelle)", + "medications": "Médicaments et historique des prises", + "moodLabs": "Entrées d'humeur et résultats de laboratoire", + "coach": "Conversations avec le coach", + "integrations": "Jetons d'intégration (chiffrés)", + "security": "Activité du compte et de sécurité (sessions, journal de connexion)" + }, + "retention": { + "title": "Conservation", + "description": "Durée de conservation des enregistrements limités dans le temps avant le nettoyage automatique.", + "coach": "Conversations avec le coach : {days} jours", + "audit": "Journal de sécurité : {days} jours", + "delivery": "Journal d'envoi des notifications : {days} jours", + "unavailable": "Les détails de conservation sont indisponibles pour le moment." + }, + "export": { + "title": "Télécharger mes données", + "description": "Exportez vos enregistrements en CSV ou JSON, éventuellement chiffrés par phrase secrète, ou générez un dossier médical complet.", + "openExport": "Export et sauvegarde", + "openHealthRecord": "Dossier médical" + }, + "delete": { + "title": "Supprimer ou réinitialiser", + "description": "Supprimez complètement votre compte, ou effacez vos données de santé tout en conservant le compte.", + "backupLag": "La suppression efface vos données immédiatement. Les sauvegardes chiffrées hors site les contiennent encore jusqu'à leur expiration selon la fenêtre de conservation de l'opérateur.", + "openAdvanced": "Supprimer ou réinitialiser" + }, + "posture": { + "title": "Posture de confidentialité", + "description": "Contrôlez la quantité de données partagées avec les fonctions d'IA et la recherche optionnelle.", + "openAi": "Mode confidentialité IA", + "openResearch": "Mode recherche" + } + } }, "admin": { "title": "Administration", @@ -5064,7 +5244,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 +5280,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 +5313,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 +5466,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 +5518,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." @@ -5521,10 +5645,55 @@ "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", - "carrierUnavailableGeoFallback": "{location} — opérateur indisponible" + "carrierUnavailableGeoFallback": "{location} — opérateur indisponible", + "mfaRequired": "Exiger un second facteur", + "mfaRequiredDescription": "Chaque compte sans deuxième facteur actif est dirigé vers la configuration après la connexion. Cela invite les utilisateurs web à l'activer, mais ne bloque pas l'accès direct par API ou natif (Bearer) pour les comptes pas encore configurés.", + "mfaEnforcedOn": "MFA activé", + "mfaEnforcedOff": "MFA désactivé", + "mfaEnforcedOnHint": "Un second facteur est requis pour cet utilisateur. Cliquez pour ne plus l'exiger.", + "mfaEnforcedOffHint": "Exiger un second facteur pour cet utilisateur." }, "achievements": { "title": "Réussites", @@ -5583,10 +5752,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 +5940,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 +5992,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 +6096,13 @@ "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" + }, + "loginRequired": "Connecte-toi pour gérer les notifications." }, "i18n": { "maintainershipBanner": { diff --git a/messages/it.json b/messages/it.json index 38c204fbb..44d04f46a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -32,10 +32,10 @@ "copy": "Copia", "copyDetails": "Copia dettagli", "retry": "Riprova", - "reportIssue": "Segnala un problema", "showPassword": "Mostra password", "hidePassword": "Nascondi password", - "learnMore": "Scopri di più" + "learnMore": "Scopri di più", + "dismiss": "Ignora" }, "doctorReport": { "title": "Rapporto sanitario", @@ -204,7 +204,6 @@ "insights": "Insights", "achievements": "Traguardi", "settings": "Impostazioni", - "bugreport": "Segnalazione bug", "admin": "Console di amministrazione", "logout": "Esci", "login": "Accedi", @@ -263,7 +262,28 @@ "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.", + "mfa": { + "title": "Autenticazione a due fattori", + "totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.", + "recoveryHint": "Inserisci uno dei tuoi codici di recupero salvati.", + "securityKeyHint": "Usa la tua chiave di sicurezza registrata per continuare.", + "codeLabel": "Codice di autenticazione", + "recoveryLabel": "Codice di recupero", + "verify": "Verifica", + "verifyFailed": "Verifica non riuscita. Riprova.", + "useSecurityKey": "Usa una chiave di sicurezza", + "useRecoveryCode": "Usa un codice di recupero", + "useAuthenticator": "Usa la tua app di autenticazione", + "backToLogin": "Torna all'accesso", + "rememberDevice": "Considera attendibile questo dispositivo per 30 giorni" + }, + "enrollMfa": { + "title": "Configura un secondo fattore", + "body": "Il tuo amministratore richiede un secondo fattore su questo account. Aggiungi un'app di autenticazione o una chiave di sicurezza per continuare.", + "signOut": "Esci" + } }, "dashboard": { "title": "Cruscotto", @@ -4057,7 +4077,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", @@ -4197,6 +4222,15 @@ "title": "Canali", "description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail.", "subtitle": "Dove vengono recapitati promemoria e avvisi." + }, + "security": { + "title": "Sicurezza", + "subtitle": "Autenticazione a due fattori, codici di recupero e passkey." + }, + "privacy": { + "title": "Dati e privacy", + "subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto.", + "description": "Esportazione, eliminazione, conservazione, stato della cifratura, sessioni e attività di sicurezza." } }, "profile": "Profilo", @@ -4968,7 +5002,153 @@ "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" + }, + "active": "Attivo", + "inactive": "Inattivo", + "stepUpRequired": "Per la tua sicurezza, accedi di nuovo per modificare il secondo fattore.", + "useRecoveryCode": "Usa invece un codice di recupero", + "useAuthenticator": "Usa invece la tua app di autenticazione", + "totp": { + "title": "App di autenticazione", + "description": "Usa un codice temporaneo di un'app di autenticazione come secondo fattore.", + "setUp": "Configura", + "step1": "Scansiona il codice QR con la tua app di autenticazione oppure inserisci il segreto manualmente.", + "step2": "Inserisci il codice a 6 cifre mostrato dall'app per confermare.", + "qrAlt": "Codice QR per la configurazione dell'app di autenticazione", + "qrUnavailable": "Codice QR non disponibile", + "manualEntry": "Oppure inserisci questo segreto manualmente", + "enterCode": "Inserisci il codice a 6 cifre", + "verify": "Verifica", + "setupFailed": "Impossibile avviare la configurazione. Riprova.", + "invalidCode": "Questo codice non è valido. Riprova.", + "disable": "Disattiva", + "disableTitle": "Disattiva l'autenticazione a due fattori", + "disableConfirm": "Inserisci un codice attuale per disattivare il secondo fattore. Questo elimina anche i tuoi codici di recupero.", + "currentCode": "Codice attuale", + "disableFailed": "Impossibile disattivare il secondo fattore." + }, + "recovery": { + "saveNow": "Salva subito questi codici di recupero", + "saveNowHint": "Ogni codice funziona una sola volta. Vengono mostrati solo questa volta: conservali in un luogo sicuro. Se perdi l'autenticatore, un codice di recupero è l'unico modo per rientrare.", + "download": "Scarica", + "remainingOne": "{count} codice di recupero rimanente.", + "remainingOther": "{count} codici di recupero rimanenti.", + "regenerate": "Rigenera i codici", + "regenFailed": "Impossibile rigenerare i codici di recupero.", + "savedIt": "Li ho salvati", + "regenerateTitle": "Rigenerare i codici di recupero?", + "regenerateConfirm": "Questa operazione sostituisce i codici di recupero attuali. I codici esistenti smetteranno di funzionare immediatamente e il nuovo set viene mostrato una sola volta." + }, + "keys": { + "title": "Chiavi di sicurezza", + "description": "Registra una chiave di sicurezza hardware o di piattaforma come secondo fattore.", + "empty": "Nessuna chiave di sicurezza ancora registrata.", + "add": "Aggiungi chiave di sicurezza", + "addFailed": "Impossibile registrare la chiave di sicurezza.", + "rename": "Rinomina", + "renameFailed": "Impossibile rinominare la chiave di sicurezza.", + "remove": "Rimuovi chiave di sicurezza", + "removeFailed": "Impossibile rimuovere la chiave di sicurezza.", + "removeConfirm": "Questa chiave di sicurezza non funzionerà più come secondo fattore.", + "lastUsed": "Usata l'ultima volta il {date}", + "neverUsed": "Mai usata", + "added": "aggiunta il {date}", + "nameLabel": "Nome della chiave di sicurezza" + }, + "passkeyNudge": { + "title": "Aggiungi una passkey per un accesso più rapido", + "body": "Le passkey ti permettono di accedere con impronta, volto o PIN del dispositivo, senza password da ricordare." + }, + "trustedDevices": { + "title": "Dispositivi attendibili", + "description": "I browser che hai scelto di considerare attendibili saltano il secondo fattore per 30 giorni. La password è comunque richiesta. Revoca quelli che non riconosci.", + "loadError": "Impossibile caricare i dispositivi attendibili.", + "revokeError": "Impossibile revocare il dispositivo. Riprova.", + "revoke": "Revoca", + "revokeAll": "Dimentica tutti i dispositivi", + "revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i.", + "unnamed": "Dispositivo sconosciuto", + "current": "Questo dispositivo", + "expires": "Attendibile fino al {date}" + } + }, + "privacy": { + "intro": { + "title": "I tuoi dati restano tuoi", + "body": "HealthLog è self-hosted. Questa pagina riunisce come sono protetti i tuoi dati, per quanto tempo vengono conservati, come esportarli e come eliminarli." + }, + "encryption": { + "title": "Cifrati a riposo", + "description": "I campi sensibili sono cifrati nel database.", + "summary": "I tuoi dati sensibili sono cifrati a riposo con {algorithm} su {columns} campi in {models} tipi di record.", + "statement": "I tuoi dati sensibili sono cifrati a riposo con AES-256-GCM." + }, + "stored": { + "title": "Cosa viene memorizzato", + "description": "Le categorie di dati contenute in questo account.", + "measurements": "Misurazioni (peso, pressione, polso, glicemia, sonno, composizione corporea)", + "medications": "Farmaci e cronologia delle assunzioni", + "moodLabs": "Voci dell'umore e referti di laboratorio", + "coach": "Conversazioni con il coach", + "integrations": "Token di integrazione (cifrati)", + "security": "Attività dell'account e di sicurezza (sessioni, registro degli accessi)" + }, + "retention": { + "title": "Conservazione", + "description": "Per quanto tempo i record a tempo vengono conservati prima della pulizia automatica.", + "coach": "Conversazioni con il coach: {days} giorni", + "audit": "Registro di sicurezza: {days} giorni", + "delivery": "Registro di invio delle notifiche: {days} giorni", + "unavailable": "I dettagli di conservazione non sono disponibili al momento." + }, + "export": { + "title": "Scarica i miei dati", + "description": "Esporta i tuoi record come CSV o JSON, eventualmente cifrati con passphrase, oppure genera un fascicolo sanitario completo.", + "openExport": "Esportazione e backup", + "openHealthRecord": "Fascicolo sanitario" + }, + "delete": { + "title": "Elimina o reimposta", + "description": "Elimina completamente il tuo account oppure cancella i tuoi dati sanitari mantenendo l'account.", + "backupLag": "L'eliminazione rimuove subito i tuoi dati. I backup cifrati esterni li contengono ancora finché non scadono secondo la finestra di conservazione dell'operatore.", + "openAdvanced": "Elimina o reimposta" + }, + "posture": { + "title": "Impostazione privacy", + "description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA e la ricerca facoltativa.", + "openAi": "Modalità privacy IA", + "openResearch": "Modalità ricerca" + } + } }, "admin": { "title": "Administration", @@ -5064,7 +5244,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 +5280,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 +5313,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 +5466,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 +5518,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." @@ -5521,10 +5645,55 @@ "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", - "carrierUnavailableGeoFallback": "{location} — operatore non disponibile" + "carrierUnavailableGeoFallback": "{location} — operatore non disponibile", + "mfaRequired": "Richiedi un secondo fattore", + "mfaRequiredDescription": "Ogni account senza un secondo fattore attivo viene indirizzato alla configurazione dopo l'accesso. Questo invita gli utenti web ad attivarlo, ma non blocca l'accesso diretto via API o nativo (Bearer) per gli account non ancora configurati.", + "mfaEnforcedOn": "MFA attivo", + "mfaEnforcedOff": "MFA disattivato", + "mfaEnforcedOnHint": "Per questo utente è richiesto un secondo fattore. Clicca per non richiederlo più.", + "mfaEnforcedOffHint": "Richiedi un secondo fattore per questo utente." }, "achievements": { "title": "Obiettivi", @@ -5583,10 +5752,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 +5940,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 +5992,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 +6096,13 @@ "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" + }, + "loginRequired": "Accedi per gestire le notifiche." }, "i18n": { "maintainershipBanner": { diff --git a/messages/pl.json b/messages/pl.json index 566625aa1..940b85f0a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -32,10 +32,10 @@ "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" + "learnMore": "Dowiedz się więcej", + "dismiss": "Odrzuć" }, "doctorReport": { "title": "Raport zdrowotny", @@ -204,7 +204,6 @@ "insights": "Insights", "achievements": "Osiągnięcia", "settings": "Ustawienia", - "bugreport": "Zgłoszenie błędu", "admin": "Konsola administratora", "logout": "Wyloguj", "login": "Zaloguj", @@ -263,7 +262,28 @@ "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.", + "mfa": { + "title": "Uwierzytelnianie dwuskładnikowe", + "totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.", + "recoveryHint": "Wprowadź jeden z zapisanych kodów odzyskiwania.", + "securityKeyHint": "Użyj zarejestrowanego klucza bezpieczeństwa, aby kontynuować.", + "codeLabel": "Kod uwierzytelniający", + "recoveryLabel": "Kod odzyskiwania", + "verify": "Zweryfikuj", + "verifyFailed": "Weryfikacja nie powiodła się. Spróbuj ponownie.", + "useSecurityKey": "Użyj klucza bezpieczeństwa", + "useRecoveryCode": "Użyj kodu odzyskiwania", + "useAuthenticator": "Użyj aplikacji uwierzytelniającej", + "backToLogin": "Powrót do logowania", + "rememberDevice": "Zaufaj temu urządzeniu na 30 dni" + }, + "enrollMfa": { + "title": "Skonfiguruj drugi składnik", + "body": "Administrator wymaga drugiego składnika na tym koncie. Dodaj aplikację uwierzytelniającą lub klucz bezpieczeństwa, aby kontynuować.", + "signOut": "Wyloguj się" + } }, "dashboard": { "title": "Pulpit", @@ -4057,7 +4077,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", @@ -4197,6 +4222,15 @@ "title": "Kanały", "description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail.", "subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia." + }, + "security": { + "title": "Bezpieczeństwo", + "subtitle": "Uwierzytelnianie dwuskładnikowe, kody odzyskiwania i klucze dostępu." + }, + "privacy": { + "title": "Dane i prywatność", + "subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu.", + "description": "Eksport, usuwanie, retencja, stan szyfrowania, sesje i aktywność zabezpieczeń." } }, "profile": "Profil", @@ -4968,7 +5002,153 @@ "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ń" + }, + "active": "Aktywne", + "inactive": "Nieaktywne", + "stepUpRequired": "Ze względów bezpieczeństwa zaloguj się ponownie, aby zmienić drugi składnik.", + "useRecoveryCode": "Użyj zamiast tego kodu odzyskiwania", + "useAuthenticator": "Użyj zamiast tego aplikacji uwierzytelniającej", + "totp": { + "title": "Aplikacja uwierzytelniająca", + "description": "Użyj kodu czasowego z aplikacji uwierzytelniającej jako drugiego składnika.", + "setUp": "Skonfiguruj", + "step1": "Zeskanuj kod QR aplikacją uwierzytelniającą lub wprowadź sekret ręcznie.", + "step2": "Wprowadź 6-cyfrowy kod wyświetlany przez aplikację, aby potwierdzić.", + "qrAlt": "Kod QR do konfiguracji aplikacji uwierzytelniającej", + "qrUnavailable": "Kod QR niedostępny", + "manualEntry": "Lub wprowadź ten sekret ręcznie", + "enterCode": "Wprowadź 6-cyfrowy kod", + "verify": "Zweryfikuj", + "setupFailed": "Nie udało się rozpocząć konfiguracji. Spróbuj ponownie.", + "invalidCode": "Ten kod jest nieprawidłowy. Spróbuj ponownie.", + "disable": "Wyłącz", + "disableTitle": "Wyłącz uwierzytelnianie dwuskładnikowe", + "disableConfirm": "Wprowadź bieżący kod, aby wyłączyć drugi składnik. Spowoduje to również usunięcie kodów odzyskiwania.", + "currentCode": "Bieżący kod", + "disableFailed": "Nie udało się wyłączyć drugiego składnika." + }, + "recovery": { + "saveNow": "Zapisz teraz te kody odzyskiwania", + "saveNowHint": "Każdy kod działa raz. Są wyświetlane tylko teraz — przechowuj je w bezpiecznym miejscu. Jeśli stracisz aplikację uwierzytelniającą, kod odzyskiwania będzie jedyną drogą powrotu.", + "download": "Pobierz", + "remainingOne": "Pozostał {count} kod odzyskiwania.", + "remainingOther": "Pozostało {count} kodów odzyskiwania.", + "regenerate": "Wygeneruj kody ponownie", + "regenFailed": "Nie udało się ponownie wygenerować kodów odzyskiwania.", + "savedIt": "Zapisałem je", + "regenerateTitle": "Wygenerować nowe kody odzyskiwania?", + "regenerateConfirm": "Spowoduje to zastąpienie obecnych kodów odzyskiwania. Dotychczasowe kody natychmiast przestaną działać, a nowy zestaw zostanie pokazany tylko raz." + }, + "keys": { + "title": "Klucze bezpieczeństwa", + "description": "Zarejestruj sprzętowy lub systemowy klucz bezpieczeństwa jako drugi składnik.", + "empty": "Nie zarejestrowano jeszcze żadnych kluczy bezpieczeństwa.", + "add": "Dodaj klucz bezpieczeństwa", + "addFailed": "Nie udało się zarejestrować klucza bezpieczeństwa.", + "rename": "Zmień nazwę", + "renameFailed": "Nie udało się zmienić nazwy klucza bezpieczeństwa.", + "remove": "Usuń klucz bezpieczeństwa", + "removeFailed": "Nie udało się usunąć klucza bezpieczeństwa.", + "removeConfirm": "Ten klucz bezpieczeństwa nie będzie już działać jako drugi składnik.", + "lastUsed": "Ostatnio użyty {date}", + "neverUsed": "Nigdy nieużywany", + "added": "dodany {date}", + "nameLabel": "Nazwa klucza bezpieczeństwa" + }, + "passkeyNudge": { + "title": "Dodaj klucz dostępu, aby logować się szybciej", + "body": "Klucze dostępu pozwalają logować się odciskiem palca, twarzą lub kodem PIN urządzenia — bez hasła do zapamiętania." + }, + "trustedDevices": { + "title": "Zaufane urządzenia", + "description": "Przeglądarki, którym zaufałeś, pomijają drugi składnik przez 30 dni. Hasło jest nadal wymagane. Cofnij zaufanie tym, których nie rozpoznajesz.", + "loadError": "Nie udało się załadować zaufanych urządzeń.", + "revokeError": "Nie udało się cofnąć urządzenia. Spróbuj ponownie.", + "revoke": "Cofnij", + "revokeAll": "Zapomnij wszystkie urządzenia", + "revokeAllDone": "Zapomniano {count} zaufane urządzenie/a.", + "unnamed": "Nieznane urządzenie", + "current": "To urządzenie", + "expires": "Zaufane do {date}" + } + }, + "privacy": { + "intro": { + "title": "Twoje dane pozostają Twoje", + "body": "HealthLog jest hostowany samodzielnie. Ta strona zbiera w jednym miejscu informacje o tym, jak chronione są Twoje dane, jak długo są przechowywane, jak je wyeksportować i jak je usunąć." + }, + "encryption": { + "title": "Szyfrowane w spoczynku", + "description": "Wrażliwe pola są szyfrowane w bazie danych.", + "summary": "Twoje wrażliwe dane są szyfrowane w spoczynku algorytmem {algorithm} w {columns} polach w {models} typach rekordów.", + "statement": "Twoje wrażliwe dane są szyfrowane w spoczynku algorytmem AES-256-GCM." + }, + "stored": { + "title": "Co jest przechowywane", + "description": "Kategorie danych przechowywanych na tym koncie.", + "measurements": "Pomiary (waga, ciśnienie, tętno, glukoza, sen, skład ciała)", + "medications": "Leki i historia przyjmowania", + "moodLabs": "Wpisy nastroju i wyniki badań laboratoryjnych", + "coach": "Rozmowy z asystentem", + "integrations": "Tokeny integracji (szyfrowane)", + "security": "Aktywność konta i zabezpieczeń (sesje, dziennik logowań)" + }, + "retention": { + "title": "Retencja", + "description": "Jak długo rekordy ograniczone czasowo są przechowywane przed automatycznym czyszczeniem.", + "coach": "Rozmowy z asystentem: {days} dni", + "audit": "Dziennik zabezpieczeń: {days} dni", + "delivery": "Dziennik wysyłki powiadomień: {days} dni", + "unavailable": "Szczegóły przechowywania są obecnie niedostępne." + }, + "export": { + "title": "Pobierz moje dane", + "description": "Wyeksportuj swoje rekordy jako CSV lub JSON, opcjonalnie zaszyfrowane hasłem, albo wygeneruj pełną dokumentację medyczną.", + "openExport": "Eksport i kopia zapasowa", + "openHealthRecord": "Dokumentacja medyczna" + }, + "delete": { + "title": "Usuń lub zresetuj", + "description": "Usuń całe konto albo wyczyść dane zdrowotne, zachowując konto.", + "backupLag": "Usunięcie natychmiast usuwa Twoje dane. Zaszyfrowane kopie zapasowe poza serwerem nadal je zawierają, dopóki nie wygasną zgodnie z oknem retencji operatora.", + "openAdvanced": "Usuń lub zresetuj" + }, + "posture": { + "title": "Ustawienia prywatności", + "description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI i opcjonalnym badaniom.", + "openAi": "Tryb prywatności AI", + "openResearch": "Tryb badawczy" + } + } }, "admin": { "title": "Administracja", @@ -5064,7 +5244,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 +5280,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 +5313,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 +5466,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 +5518,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." @@ -5521,10 +5645,55 @@ "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", - "carrierUnavailableGeoFallback": "{location} — operator niedostępny" + "carrierUnavailableGeoFallback": "{location} — operator niedostępny", + "mfaRequired": "Wymagaj drugiego składnika", + "mfaRequiredDescription": "Każde konto bez aktywnego drugiego składnika jest po zalogowaniu kierowane do konfiguracji. To zachęca użytkowników sieci do włączenia go, ale nie blokuje bezpośredniego dostępu przez API ani natywnego (Bearer) dla kont jeszcze nieskonfigurowanych.", + "mfaEnforcedOn": "MFA wł.", + "mfaEnforcedOff": "MFA wył.", + "mfaEnforcedOnHint": "Ten użytkownik musi używać drugiego składnika. Kliknij, aby przestać go wymagać.", + "mfaEnforcedOffHint": "Wymagaj drugiego składnika dla tego użytkownika." }, "achievements": { "title": "Osiągnięcia", @@ -5583,10 +5752,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 +5940,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 +5992,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 +6096,13 @@ "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" + }, + "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami." }, "i18n": { "maintainershipBanner": { diff --git a/package.json b/package.json index 29b6f8321..a17f1ac9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.22.1", + "version": "1.23.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", @@ -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/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/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/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/migrations/0204_v123_trusted_devices/migration.sql b/prisma/migrations/0204_v123_trusted_devices/migration.sql new file mode 100644 index 000000000..6cffaf903 --- /dev/null +++ b/prisma/migrations/0204_v123_trusted_devices/migration.sql @@ -0,0 +1,35 @@ +-- v1.23 — admin-enforced MFA policy + "remember this device" trusted devices. +-- +-- 1. `app_settings.mfa_required` — the instance-wide "require a second factor" +-- policy. When true, every account without an active second factor is sent +-- through a forced-enrollment interstitial after sign-in. The effective +-- per-user requirement is `mfa_required OR users.mfa_enforced`. +-- +-- 2. `trusted_devices` — the opt-in "remember this device" store. Only the HMAC +-- hash of the cookie token is persisted (keyed by API_TOKEN_HMAC_KEY); the +-- raw token lives only in the user's httpOnly+Secure cookie. A trusted +-- device skips factor 2 within the 30-day window but never satisfies step-up +-- and never replaces the password. Revoked when the second factor is removed. + +ALTER TABLE "app_settings" ADD COLUMN "mfa_required" BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE "trusted_devices" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "label" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "last_used_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "trusted_devices_pkey" PRIMARY KEY ("id") +); + +-- The token hash is the lookup key on every trusted-device sign-in, and a +-- token is single-purpose per device, so the hash is globally unique. +CREATE UNIQUE INDEX "trusted_devices_token_hash_key" ON "trusted_devices"("token_hash"); +CREATE INDEX "trusted_devices_user_id_idx" ON "trusted_devices"("user_id"); +-- The expired-trusted-device reaper sweeps on `expires_at`. +CREATE INDEX "trusted_devices_expires_at_idx" ON "trusted_devices"("expires_at"); + +ALTER TABLE "trusted_devices" ADD CONSTRAINT "trusted_devices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 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 dfcb5020a..01c290de9 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,77 @@ 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") + // 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") + // 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[] + knownDevices UserKnownDevice[] + // v1.23 — "remember this device" trusted-device tokens. A trusted device + // skips the second factor (factor 2 only) within the window; the password + // is still required. Revoked when the second factor is removed. + trustedDevices TrustedDevice[] + 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[] + 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 +742,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 +772,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 +792,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 +813,127 @@ 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") +} + +// 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") +} + +// v1.23 — "remember this device" trusted-device token. +// +// At the second-factor step the user may opt in to trusting the browser for 30 +// days. On opt-in a random token is minted, set in an httpOnly+Secure cookie, +// and only its HMAC hash (`tokenHash`, keyed by `API_TOKEN_HMAC_KEY`) is stored +// here — a leaked row cannot reconstruct a usable cookie. On a later sign-in +// from the same browser the password is still required (factor 1); a matching, +// unexpired trusted-device row lets the login skip factor 2 only. The token is +// NEVER accepted for step-up (`requireFreshMfa`) — a stolen cookie cannot reach +// a destructive action. Revocable from the device list and revoked wholesale +// when the second factor is removed or the user signs out everywhere. +model TrustedDevice { + id String @id @default(cuid()) + userId String @map("user_id") + tokenHash String @unique @map("token_hash") + label String? + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + lastUsedAt DateTime @default(now()) @map("last_used_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + // The reaper for expired trusted devices sweeps on `expiresAt`. + @@index([expiresAt]) + @@map("trusted_devices") +} + model AuthChallenge { id String @id @default(cuid()) userId String? @map("user_id") // null for registration @@ -1074,8 +1229,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 +1241,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 +1286,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 +1294,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 +1404,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 +1675,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 +1790,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 +1801,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 +1816,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 +1838,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 +1848,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 +1905,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 +1944,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 +1953,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 +1970,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 +1993,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 +2023,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 +2050,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 +2076,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 +2144,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 +2157,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 +2169,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 +2190,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 +2653,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 +2667,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 +2675,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 +2727,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 +2747,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 +2761,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 +2781,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 +2836,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 +2883,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 +2896,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 +2915,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 +2939,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) @@ -3080,6 +3248,14 @@ model IntegrationStatus { model AppSettings { id String @id @default("singleton") registrationEnabled Boolean @default(true) @map("registration_enabled") + // v1.23 — instance-wide "require a second factor" policy. When true, every + // account without an active second factor (no confirmed TOTP and no + // registered security key) is sent through a forced-enrollment interstitial + // after sign-in until it enrolls one. A per-user override lives on + // `User.mfaEnforced`; the effective requirement is the OR of the two. The + // operator/CLI escape hatch (`scripts/disable-mfa.ts`, toggling this off) + // keeps the policy from locking anyone out irrecoverably. + mfaRequired Boolean @default(false) @map("mfa_required") defaultLocale String @default("de") @map("default_locale") // "de" | "en" telegramGlobal Boolean @default(true) @map("telegram_global") // global toggle ntfyGlobal Boolean @default(true) @map("ntfy_global") // global toggle @@ -3095,13 +3271,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") @@ -3172,52 +3341,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 { @@ -3467,23 +3590,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 +3614,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 +3753,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 +3785,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 +4150,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 +4308,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 +4320,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 +4631,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 +4664,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 +4698,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 +4708,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 +4741,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 +4882,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 +4926,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 +5015,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/public/sw.js b/public/sw.js index 069510dbe..e16e622bc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.22.0"; + /* @sw-version-fallback */ "v1.22.1"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` 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/scripts/disable-mfa.ts b/scripts/disable-mfa.ts new file mode 100644 index 000000000..ba232baa6 --- /dev/null +++ b/scripts/disable-mfa.ts @@ -0,0 +1,101 @@ +/** + * 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 } }); + // v1.23 (M-review L3) — removing the second factor is a security-state + // change. Out-of-band there is no "current" session to keep, so drop every + // session, every native refresh token, and every "remember this device" + // trusted device for the account: a stolen session or trusted-device cookie + // must not survive the lockout-recovery reset. + await tx.session.deleteMany({ where: { userId: user.id } }); + await tx.refreshToken.updateMany({ + where: { userId: user.id, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + await tx.trustedDevice.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/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 41e8ee4af..79e11d648 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)); @@ -225,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), @@ -405,6 +405,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/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts new file mode 100644 index 000000000..e59dffb9d --- /dev/null +++ b/src/__tests__/api/auth/login/mfa-branch.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) }, + }, +})); +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(); + // Default: no registered security key. Individual tests override as needed. + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); +}); + +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("WebAuthn-only user (no TOTP) → returns mfaRequired with webauthn method", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...BASE_USER, + totpConfirmedAt: null, + } as never); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never); + + const res = await POST(loginRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.meta.mfaRequired).toBe(true); + expect(body.meta.methods).toEqual(["webauthn"]); + 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/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..dcac2cc13 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -25,7 +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 { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section"; +import { EncryptionSection } from "@/components/admin/encryption-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 +120,6 @@ export function AdminSectionRenderer({ ); - case "feedback": - return ( - - - - ); case "reminders": return ( ); + case "encryption": + return ( + + + + ); case "danger-zone": return ( { + // 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/app/api/admin/encryption/status/route.ts b/src/app/api/admin/encryption/status/route.ts new file mode 100644 index 000000000..76cc2b02f --- /dev/null +++ b/src/app/api/admin/encryption/status/route.ts @@ -0,0 +1,137 @@ +/** + * GET /api/admin/encryption/status + * + * v1.23 — read-only admin view of encryption coverage + rotation progress. + * Buckets every registered encrypted column's rows by key id (the same scan the + * rotation tooling does), so an operator can SEE whether a rotation finished + * before dropping a legacy key, instead of guessing. + * + * Returns ONLY key IDS (operator-chosen labels like `v1` / `v2`) and ROW + * COUNTS — never key material. The configured-key set is surfaced as a count. + * + * Auth: cookie-only `requireAdmin` (a Bearer token can never reach admin). The + * scan is on-demand with a short in-process cache so repeated panel renders + * don't re-scan the corpus each time; the rotation run state is read from the + * audit trail (DB-backed, so it is correct across the web/worker processes). + */ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { getConfiguredKeyIds } from "@/lib/crypto"; +import { + scanCorpus, + type CorpusClient, + type CorpusScan, +} from "@/lib/crypto/encryption-corpus"; + +const SCAN_CACHE_TTL_MS = 15_000; +let scanCache: { at: number; scan: CorpusScan } | null = null; + +interface RotationState { + state: "idle" | "running" | "completed" | "failed"; + lastRequestedAt: string | null; + lastCompletedAt: string | null; + lastResult: { scanned: number; rotated: number; errors: number } | null; +} + +async function readRotationState(): Promise { + 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/app/api/admin/feedback/[id]/__tests__/route.test.ts b/src/app/api/admin/feedback/[id]/__tests__/route.test.ts deleted file mode 100644 index c2f48e6a0..000000000 --- a/src/app/api/admin/feedback/[id]/__tests__/route.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * v1.4.43 W6 — multi-issue 422 envelope on PATCH /api/admin/feedback/[id]. - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/db", () => ({ - 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..51ad16dcc 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"; @@ -30,6 +29,7 @@ export const GET = apiHandler(async () => { return apiSuccess({ registrationEnabled: settings?.registrationEnabled ?? true, + mfaRequired: settings?.mfaRequired ?? false, defaultLocale: settings?.defaultLocale ?? "de", telegramGlobal: settings?.telegramGlobal ?? true, ntfyGlobal: settings?.ntfyGlobal ?? true, @@ -48,11 +48,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, @@ -85,6 +80,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { // Boolean fields — direct mapping const booleanFields = [ "registrationEnabled", + "mfaRequired", "telegramGlobal", "ntfyGlobal", "webPushGlobal", @@ -92,7 +88,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 +166,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 +210,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), @@ -246,6 +218,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { return apiSuccess({ registrationEnabled: settings.registrationEnabled, + mfaRequired: settings.mfaRequired, defaultLocale: settings.defaultLocale, telegramGlobal: settings.telegramGlobal, ntfyGlobal: settings.ntfyGlobal, @@ -264,11 +237,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/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/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index d682f316f..7270e4b93 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -16,6 +16,9 @@ const updateUserSchema = z.object({ username: z.string().min(3).max(30).optional(), email: z.string().email().nullable().optional(), role: z.enum(["ADMIN", "USER"]).optional(), + // v1.23 — per-user "require a second factor" override. Effective requirement + // is the OR of this and the instance-wide AppSettings.mfaRequired policy. + mfaEnforced: z.boolean().optional(), }); export const PUT = apiHandler( @@ -64,6 +67,9 @@ export const PUT = apiHandler( }), ...(parsed.data.email !== undefined && { email: parsed.data.email }), ...(parsed.data.role !== undefined && { role: parsed.data.role }), + ...(parsed.data.mfaEnforced !== undefined && { + mfaEnforced: parsed.data.mfaEnforced, + }), }, select: { id: true, @@ -71,6 +77,7 @@ export const PUT = apiHandler( email: true, role: true, createdAt: true, + mfaEnforced: true, }, }); diff --git a/src/app/api/admin/users/__tests__/route.test.ts b/src/app/api/admin/users/__tests__/route.test.ts index ed756306a..4e7a8bd1b 100644 --- a/src/app/api/admin/users/__tests__/route.test.ts +++ b/src/app/api/admin/users/__tests__/route.test.ts @@ -111,6 +111,7 @@ describe("GET /api/admin/users", () => { email: true, role: true, createdAt: true, + mfaEnforced: true, _count: { select: { passkeys: true } }, }); expect(args.select).not.toHaveProperty("passwordHash"); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 573891c7f..60d0097ba 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -16,6 +16,7 @@ export const GET = apiHandler(async () => { email: true, role: true, createdAt: true, + mfaEnforced: true, _count: { select: { passkeys: true } }, }, orderBy: { createdAt: "asc" }, @@ -28,6 +29,9 @@ export const GET = apiHandler(async () => { email: u.email, role: u.role, createdAt: u.createdAt, + // v1.23 — per-user "require a second factor" override. The effective + // requirement is the OR of this and the instance-wide policy. + mfaEnforced: u.mfaEnforced, passkeyCount: u._count.passkeys, })), ); diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts index 6661fe644..fc1212912 100644 --- a/src/app/api/auth/login/__tests__/native-token.test.ts +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({ user: { findFirst: vi.fn() }, apiToken: { create: vi.fn() }, refreshToken: { create: vi.fn() }, + // v1.23 — login checks for a registered second-factor security key. + webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) }, }, })); @@ -15,6 +17,14 @@ vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn(), })); +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/login-alert", () => ({ + recordSignInDevice: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn().mockResolvedValue("session-id"), setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined), @@ -97,6 +107,7 @@ function makeRequest(headers: Record = {}): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); vi.mocked(verifyPassword).mockResolvedValue(true); vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never); vi.mocked(prisma.refreshToken.create).mockResolvedValue({ diff --git a/src/app/api/auth/login/__tests__/trusted-device.test.ts b/src/app/api/auth/login/__tests__/trusted-device.test.ts new file mode 100644 index 000000000..cb895be6b --- /dev/null +++ b/src/app/api/auth/login/__tests__/trusted-device.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) }, + }, +})); + +vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn() })); + +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi.fn(async () => new Response(null, { status: 200 })), +})); + +vi.mock("@/lib/auth/mfa/challenge", () => ({ + createMfaChallenge: vi.fn(async () => ({ + ticket: "tkt", + expiresAt: new Date(), + })), +})); + +vi.mock("@/lib/auth/trusted-device", () => ({ + consumeTrustedDevice: vi.fn(), +})); + +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + checkAuthSurfaceRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, ip: "1.2.3.4" }), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +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 { verifyPassword } from "@/lib/auth/password"; +import { finishLogin } from "@/lib/auth/login-response"; +import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; +import { consumeTrustedDevice } from "@/lib/auth/trusted-device"; + +const MFA_USER = { + id: "u1", + username: "u", + passwordHash: "hash", + onboardingCompletedAt: null, + totpConfirmedAt: new Date(), + mfaEnforced: false, +}; + +function req() { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "u@example.com", password: "pw-correct" }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findFirst).mockResolvedValue(MFA_USER as never); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); +}); + +describe("POST /api/auth/login — trusted device", () => { + it("skips the second factor when the device is trusted (no challenge minted)", async () => { + vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(consumeTrustedDevice).mockResolvedValue(true); + + const res = await POST(req()); + expect(res.status).toBe(200); + expect(createMfaChallenge).not.toHaveBeenCalled(); + expect(finishLogin).toHaveBeenCalledWith( + expect.objectContaining({ source: "login.password.trusted_device" }), + ); + // A trusted-device login is NOT step-up fresh — mfaVerified must be absent. + const arg = vi.mocked(finishLogin).mock.calls[0][0]; + expect(arg.mfaVerified).toBeUndefined(); + }); + + it("falls back to the MFA challenge when the device is not trusted", async () => { + vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(consumeTrustedDevice).mockResolvedValue(false); + + const res = await POST(req()); + expect(res.status).toBe(200); + const body = (await res.json()) as { meta?: { mfaRequired?: boolean } }; + expect(body.meta?.mfaRequired).toBe(true); + expect(createMfaChallenge).toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("still requires the password — a wrong password never consults the trusted device", async () => { + vi.mocked(verifyPassword).mockResolvedValue(false); + + const res = await POST(req()); + expect(res.status).toBe(401); + expect(consumeTrustedDevice).not.toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index c4e727ee0..8093c3a80 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,22 +1,18 @@ 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"; +import { consumeTrustedDevice } from "@/lib/auth/trusted-device"; +import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; export const POST = apiHandler(async (request: NextRequest) => { // v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter @@ -95,93 +91,107 @@ 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.23 — second-factor gate. The password is correct, but an account with + // a confirmed second factor (a TOTP secret and/or a registered WebAuthn + // security key) 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` + // (TOTP / recovery) or `/api/auth/mfa/webauthn/verify` (security key). + // + // 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. + const hasTotp = Boolean(user.totpConfirmedAt); + const webauthnKeyCount = await prisma.webauthnMfaCredential.count({ + where: { userId: user.id }, + }); + const hasWebauthn = webauthnKeyCount > 0; - // 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", { + if (hasTotp || hasWebauthn) { + // v1.23 — "remember this device". A browser the user previously trusted + // skips factor 2 within the 30-day window. The password (factor 1) has + // already been verified above, so a trusted device only ever drops the + // second challenge — it never replaces the password. The minted session is + // deliberately NOT stamped `mfaVerifiedAt` (mfaVerified omitted below), so a + // trusted-device login can never satisfy step-up (`requireFreshMfa`): a + // stolen device cookie cannot reach a destructive action. + if (await consumeTrustedDevice(user.id)) { + await auditLog("auth.mfa.trusted_device", { userId: user.id, ipAddress: ip, - details: { source: "login.password", policy: "native" }, }); annotate({ - action: { name: "auth.token.autoissue.native" }, - meta: { token_policy: "native" }, + action: { name: "auth.mfa.trusted_device" }, + meta: { mfa_skipped: true }, }); - return apiSuccess({ - user: { id: user.id, username: user.username }, - token: bundle.accessToken, - tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(), - refreshToken: bundle.refreshToken, - refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(), + // The account has a second factor, so the enforcement gate is satisfied; + // keep the hint cookie honest. + await syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }); + return finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "login.password.trusted_device", }); } - // 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", { + const challenge = await createMfaChallenge(user.id, "login"); + // Recovery codes are only ever issued alongside TOTP enrollment, so the + // recovery method is offered exactly when TOTP is active. + const methods: ("totp" | "recovery" | "webauthn")[] = []; + if (hasTotp) methods.push("totp", "recovery"); + if (hasWebauthn) methods.push("webauthn"); + 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, + }, + }, + { status: 200 }, + ); } - return apiSuccess({ - user: { id: user.id, username: user.username }, + annotate({ action: { name: "auth.login.password" } }); + + // v1.23 — admin-enforced MFA policy. A single-factor account under the policy + // is sent to forced enrollment after sign-in; the proxy reads this hint + // cookie without a DB round-trip. Cheap (short-circuits when not enforced). + await syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }); + + // 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/me/mfa/disable/__tests__/route.test.ts b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts new file mode 100644 index 000000000..dfd88440c --- /dev/null +++ b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + requireFreshMfa: vi.fn(), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + $transaction: vi.fn(), + user: { update: vi.fn() }, + mfaRecoveryCode: { deleteMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ + destroyOtherSessions: vi.fn().mockResolvedValue({ sessionsRevoked: 2 }), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/mfa/verify-factor", () => ({ + verifyMfaFactor: vi.fn(), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +import { POST } from "../route"; +import { requireFreshMfa } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { destroyOtherSessions } from "@/lib/auth/session"; +import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; + +function req() { + return new Request("http://localhost/api/auth/me/mfa/disable", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ method: "totp", code: "123456" }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireFreshMfa).mockResolvedValue({ + user: { id: "u1" }, + session: { id: "sess-current" }, + } as never); + vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true } as never); + vi.mocked(prisma.$transaction).mockImplementation(async (cb: unknown) => { + if (typeof cb === "function") { + return (cb as (tx: unknown) => unknown)({ + user: { update: vi.fn() }, + mfaRecoveryCode: { deleteMany: vi.fn() }, + }); + } + return undefined; + }); +}); + +describe("POST /api/auth/me/mfa/disable", () => { + it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => { + const res = await POST(req()); + expect(res.status).toBe(200); + expect(destroyOtherSessions).toHaveBeenCalledWith("u1", "sess-current"); + }); + + it("rejects a wrong current factor without revoking anything", async () => { + vi.mocked(verifyMfaFactor).mockResolvedValue({ + ok: false, + replay: false, + } as never); + const res = await POST(req()); + expect(res.status).toBe(401); + expect(destroyOtherSessions).not.toHaveBeenCalled(); + }); +}); 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..d5ab54a18 --- /dev/null +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -0,0 +1,113 @@ +/** + * 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, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { destroyOtherSessions } from "@/lib/auth/session"; +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, session } = 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; + } + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 4096, + }); + if (jsonError) { + 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 } }); + }); + + // v1.23 (M-review L3) — removing the second factor is a security-state + // change: drop every OTHER web session, every native refresh token, and + // every "remember this device" trusted device (all handled by + // destroyOtherSessions), keeping only the caller's current session. A stale + // session or a trusted-device cookie must not survive the factor's removal. + await destroyOtherSessions(user.id, session.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/route.ts b/src/app/api/auth/me/mfa/route.ts new file mode 100644 index 000000000..2498f5e62 --- /dev/null +++ b/src/app/api/auth/me/mfa/route.ts @@ -0,0 +1,40 @@ +/** + * GET /api/auth/me/mfa + * + * Second-factor status for the security settings hub: whether TOTP is active, + * how many recovery codes remain, and the registered WebAuthn security keys. + * Cookie-only (`requireCookieAuth`) — managing MFA is never a Bearer surface. + * + * Returns only metadata: no secret, no recovery-code plaintext, no public key. + */ +import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireCookieAuth(); + + const [recoveryCodesRemaining, webauthn] = await Promise.all([ + countRemainingRecoveryCodes(user.id), + prisma.webauthnMfaCredential.findMany({ + where: { userId: user.id }, + select: { id: true, name: true, createdAt: true, lastUsedAt: true }, + orderBy: { createdAt: "desc" }, + }), + ]); + + annotate({ action: { name: "auth.mfa.status" } }); + + return apiSuccess({ + totp: { enabled: Boolean(user.totpConfirmedAt) }, + recoveryCodesRemaining, + webauthn, + // The security hub also drives the passkey-upgrade nudge from this one + // status read, so it knows whether the user has already dismissed it. + passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed), + }); +}); 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..12bf3b78e --- /dev/null +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -0,0 +1,134 @@ +/** + * 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, + safeJson, +} 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"; +import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; + +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"); + } + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 4096, + }); + if (jsonError) { + 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() }, + }); + }); + + // v1.23 — the account now has an active second factor, so any + // admin-enforced forced-enrollment redirect must clear immediately. + await setMfaEnrollCookie(false); + + 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/app/api/auth/me/mfa/webauthn/[id]/route.ts b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts new file mode 100644 index 000000000..8c83c7981 --- /dev/null +++ b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts @@ -0,0 +1,95 @@ +/** + * PATCH /api/auth/me/mfa/webauthn/[id] — rename a registered security key. + * DELETE /api/auth/me/mfa/webauthn/[id] — remove one (step-up gated). + * + * Rename is cookie-authenticated (non-destructive). Removal is a sensitive + * action: it is gated on a fresh second-factor step-up (`requireFreshMfa`, + * cookie-only — a Bearer token can never satisfy it). + */ +import { NextRequest } from "next/server"; +import { + apiHandler, + requireCookieAuth, + requireFreshMfa, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; +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"; +import { mfaWebauthnRenameSchema } from "@/lib/validations/mfa"; + +export const dynamic = "force-dynamic"; + +export const PATCH = apiHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) => { + const { user } = await requireCookieAuth(); + const { id } = await params; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = mfaWebauthnRenameSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + + const existing = await prisma.webauthnMfaCredential.findUnique({ + where: { id }, + select: { userId: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Security key not found", 404); + } + + const updated = await prisma.webauthnMfaCredential.update({ + where: { id }, + data: { name: parsed.data.name }, + select: { id: true, name: true, createdAt: true, lastUsedAt: true }, + }); + + annotate({ action: { name: "auth.mfa.webauthn.rename" } }); + return apiSuccess(updated); + }, +); + +export const DELETE = apiHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) => { + // 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 { id } = await params; + + const existing = await prisma.webauthnMfaCredential.findUnique({ + where: { id }, + select: { userId: true, name: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Security key not found", 404); + } + + await prisma.webauthnMfaCredential.delete({ where: { id } }); + + await auditLog("auth.mfa.webauthn.remove", { + userId: user.id, + ipAddress: getClientIp(request), + details: { credentialId: id, name: existing.name }, + }); + annotate({ action: { name: "auth.mfa.webauthn.remove" } }); + + return apiSuccess({ success: true }); + }, +); diff --git a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts new file mode 100644 index 000000000..37551d36d --- /dev/null +++ b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, + requireCookieAuth: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { webauthnMfaCredential: { create: vi.fn() } }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/mfa/webauthn", () => ({ + createMfaRegistrationOptions: vi.fn(), + verifyMfaRegistration: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { POST as REGISTER_OPTIONS } from "../options/route"; +import { POST as REGISTER_VERIFY } from "../verify/route"; +import { requireCookieAuth } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + createMfaRegistrationOptions, + verifyMfaRegistration, +} from "@/lib/auth/mfa/webauthn"; + +const USER = { id: "user-1", username: "u", email: "u@example.com" }; + +function verifyRequest(body: Record): NextRequest { + return new NextRequest( + "http://localhost/api/auth/me/mfa/webauthn/register/verify", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); +}); + +describe("POST /api/auth/me/mfa/webauthn/register/options", () => { + it("returns ceremony options + challenge id", async () => { + vi.mocked(createMfaRegistrationOptions).mockResolvedValue({ + options: { challenge: "abc" }, + challengeId: "ch-1", + } as never); + + const res = await REGISTER_OPTIONS(); + const body = (await res.json()) as { + data: { options: unknown; challengeId: string }; + }; + + expect(res.status).toBe(200); + expect(body.data.challengeId).toBe("ch-1"); + expect(createMfaRegistrationOptions).toHaveBeenCalledWith( + "user-1", + "u@example.com", + ); + }); +}); + +describe("POST /api/auth/me/mfa/webauthn/register/verify", () => { + const credential = { + id: "cred-1", + rawId: "cred-1", + type: "public-key", + response: { transports: ["usb"] }, + }; + + it("stores the credential when verification succeeds", async () => { + vi.mocked(verifyMfaRegistration).mockResolvedValue({ + verified: true, + registrationInfo: { + credential: { + id: "cred-1", + publicKey: new Uint8Array([1, 2, 3]), + counter: 0, + }, + }, + } as never); + vi.mocked(prisma.webauthnMfaCredential.create).mockResolvedValue({ + id: "row-1", + name: "My key", + createdAt: new Date(), + lastUsedAt: null, + } as never); + + const res = await REGISTER_VERIFY( + verifyRequest({ challengeId: "ch-1", credential, name: "My key" }), + ); + const body = (await res.json()) as { data: { id: string; name: string } }; + + expect(res.status).toBe(200); + expect(body.data.id).toBe("row-1"); + expect(verifyMfaRegistration).toHaveBeenCalledWith( + "ch-1", + "user-1", + credential, + ); + const createArgs = vi.mocked(prisma.webauthnMfaCredential.create).mock + .calls[0][0]; + expect(createArgs.data.userId).toBe("user-1"); + expect(createArgs.data.credentialId).toBe("cred-1"); + expect(createArgs.data.name).toBe("My key"); + }); + + it("returns 400 when verification fails", async () => { + vi.mocked(verifyMfaRegistration).mockResolvedValue({ + verified: false, + } as never); + + const res = await REGISTER_VERIFY( + verifyRequest({ challengeId: "ch-1", credential }), + ); + + expect(res.status).toBe(400); + expect(prisma.webauthnMfaCredential.create).not.toHaveBeenCalled(); + }); + + it("returns 422 for a malformed body", async () => { + const res = await REGISTER_VERIFY(verifyRequest({ challengeId: "ch-1" })); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts new file mode 100644 index 000000000..350cf7f82 --- /dev/null +++ b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts @@ -0,0 +1,40 @@ +/** + * POST /api/auth/me/mfa/webauthn/register/options + * + * Begin registering a WebAuthn security key as a second factor. Cookie-only — + * an API token can never enrol MFA. Returns the SimpleWebAuthn creation + * options + the server-issued challenge id to present back at /register/verify. + */ +import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async () => { + const { user } = await requireCookieAuth(); + + const rl = await checkRateLimit( + `mfa:webauthn:register:${user.id}`, + 10, + 15 * 60 * 1000, + ); + 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 { options, challengeId } = await createMfaRegistrationOptions( + user.id, + user.email ?? user.username, + ); + + annotate({ action: { name: "auth.mfa.webauthn.register-options" } }); + + return apiSuccess({ options, challengeId }); +}); diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts new file mode 100644 index 000000000..8a9e0c5d7 --- /dev/null +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -0,0 +1,74 @@ +/** + * POST /api/auth/me/mfa/webauthn/register/verify + * + * Finish registering a WebAuthn security key as a second factor. Cookie-only. + * Verifies the attestation against the user-bound challenge and stores the + * credential in `WebauthnMfaCredential` (kept separate from primary passkeys). + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +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"; +import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn"; +import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa"; +import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireCookieAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + const { challengeId, credential, name } = parsed.data; + + const verification = await verifyMfaRegistration( + challengeId, + user.id, + credential, + ); + if (!verification.verified || !verification.registrationInfo) { + return apiError("Security key verification failed", 400); + } + + const { registrationInfo } = verification; + const created = await prisma.webauthnMfaCredential.create({ + data: { + userId: user.id, + name: name ?? "Security key", + credentialId: registrationInfo.credential.id, + credentialPublicKey: Buffer.from(registrationInfo.credential.publicKey), + counter: BigInt(registrationInfo.credential.counter), + transports: + ((credential.response as Record | undefined) + ?.transports as string[]) ?? [], + }, + select: { id: true, name: true, createdAt: true, lastUsedAt: true }, + }); + + // v1.23 — a registered security key satisfies an admin-enforced MFA policy, + // so clear any forced-enrollment redirect immediately. + await setMfaEnrollCookie(false); + + await auditLog("auth.mfa.webauthn.register", { + userId: user.id, + ipAddress: getClientIp(request), + }); + annotate({ action: { name: "auth.mfa.webauthn.register" } }); + + return apiSuccess(created); +}); diff --git a/src/app/api/auth/me/passkey-nudge/dismiss/route.ts b/src/app/api/auth/me/passkey-nudge/dismiss/route.ts new file mode 100644 index 000000000..d21d0d3c6 --- /dev/null +++ b/src/app/api/auth/me/passkey-nudge/dismiss/route.ts @@ -0,0 +1,24 @@ +/** + * POST /api/auth/me/passkey-nudge/dismiss + * + * Permanently dismiss the "add a passkey" upgrade nudge for the calling user. + * Cookie-only; idempotent. Sets `User.passkeyUpgradeNudgeDismissed = true`. + */ +import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async () => { + const { user } = await requireCookieAuth(); + + await prisma.user.update({ + where: { id: user.id }, + data: { passkeyUpgradeNudgeDismissed: true }, + }); + + annotate({ action: { name: "auth.passkey.nudge.dismiss" } }); + return apiSuccess({ dismissed: true }); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 4ef9ebe53..615a47f43 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -2,6 +2,7 @@ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; +import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { buildAvatarUrl } from "@/lib/avatar"; import { decrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; @@ -26,8 +27,16 @@ export const GET = apiHandler(async () => { // cycle-tracking gate; no row is forced, a NULL toggle derives from // gender) is a Postgres round-trip. Running them sequentially added // the cookie hop to every /me — and /me sits on every app boot path. - const [, cycleProfile, modules, moduleAvailability] = await Promise.all([ + const [, , cycleProfile, modules, moduleAvailability] = await Promise.all([ setOnboardingPendingCookie(user.onboardingCompletedAt == null), + // v1.23 — keep the admin-enforced-MFA hint cookie honest on every app + // boot, mirroring the onboarding-cookie resync. A locally edited cookie is + // corrected here; an account that enrols (or has the policy lifted) loses + // the redirect on the next /me read. + syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }), prisma.cycleProfile.findUnique({ where: { userId: user.id }, select: { cycleTrackingEnabled: true }, 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/app/api/auth/me/trusted-devices/[id]/route.ts b/src/app/api/auth/me/trusted-devices/[id]/route.ts new file mode 100644 index 000000000..82f511c1d --- /dev/null +++ b/src/app/api/auth/me/trusted-devices/[id]/route.ts @@ -0,0 +1,35 @@ +/** + * DELETE /api/auth/me/trusted-devices/[id] + * + * Revoke a single trusted device, scoped to the authenticated user (a foreign + * id returns 404, never another user's row). When the revoked device is the + * caller's own, the trusted-device cookie is cleared too. Cookie-only. + */ +import { apiHandler, requireCookieAuth } 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 { revokeTrustedDevice } from "@/lib/auth/trusted-device"; + +export const dynamic = "force-dynamic"; + +export const DELETE = apiHandler( + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { user } = await requireCookieAuth(); + const { id } = await params; + + const revoked = await revokeTrustedDevice(user.id, id); + if (!revoked) { + annotate({ action: { name: "auth.trusted_device.revoke.not_found" } }); + return apiError("Trusted device not found", 404); + } + + await auditLog("auth.trusted_device.revoke", { + userId: user.id, + ipAddress: getClientIp(req), + }); + annotate({ action: { name: "auth.trusted_device.revoke" } }); + + return apiSuccess({ revoked: true }); + }, +); diff --git a/src/app/api/auth/me/trusted-devices/route.ts b/src/app/api/auth/me/trusted-devices/route.ts new file mode 100644 index 000000000..228c91ef5 --- /dev/null +++ b/src/app/api/auth/me/trusted-devices/route.ts @@ -0,0 +1,57 @@ +/** + * GET /api/auth/me/trusted-devices — list the caller's trusted devices. + * DELETE /api/auth/me/trusted-devices — revoke ALL of them ("forget every + * device"); also clears the caller's own trusted-device cookie. + * + * Cookie-only (`requireCookieAuth`) — a trusted device is a browser concept and + * a Bearer client never manages one. Returns only metadata: a coarse, IP-free + * device label and the lifecycle timestamps; never the token or its hash. + */ +import { apiHandler, requireCookieAuth } 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 { + listTrustedDevices, + revokeAllTrustedDevices, + clearTrustedDeviceCookie, +} from "@/lib/auth/trusted-device"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireCookieAuth(); + annotate({ action: { name: "auth.trusted_device.list" } }); + + const devices = await listTrustedDevices(user.id); + return apiSuccess({ + devices: devices.map((d) => ({ + id: d.id, + label: d.label, + createdAt: d.createdAt.toISOString(), + lastUsedAt: d.lastUsedAt.toISOString(), + expiresAt: d.expiresAt.toISOString(), + isCurrent: d.isCurrent, + })), + }); +}); + +export const DELETE = apiHandler(async (req: Request) => { + const { user } = await requireCookieAuth(); + + const revoked = await revokeAllTrustedDevices(user.id); + await clearTrustedDeviceCookie(); + + await auditLog("auth.trusted_device.revoke_all", { + userId: user.id, + ipAddress: getClientIp(req), + details: { revoked }, + }); + annotate({ + action: { name: "auth.trusted_device.revoke_all" }, + meta: { revoked }, + }); + + return apiSuccess({ revoked }); +}); 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..67a4028d9 --- /dev/null +++ b/src/app/api/auth/mfa/verify/route.ts @@ -0,0 +1,164 @@ +/** + * 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"; +import { mintTrustedDevice } from "@/lib/auth/trusted-device"; +import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint"; +import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; + +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, rememberDevice } = 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); + } + + // 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: { + 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"); + + // The user just completed a second factor, so any forced-enrollment redirect + // must clear immediately. + await setMfaEnrollCookie(false); + + // v1.23 — "remember this device". Mint the trusted-device token + cookie + // (only its hash is stored) so subsequent logins from this browser skip the + // second factor for 30 days. Set BEFORE finishLogin so both cookies land on + // the same response. A recovery-code login is excluded: it signals the user + // lost their device, so trusting the current browser would be unsafe. + if (rememberDevice && method !== "recovery") { + await mintTrustedDevice(user.id, coarseDeviceLabel(ua)); + } + + return finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "mfa.verify", + mfaVerified: true, + }); +}); diff --git a/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts b/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts new file mode 100644 index 000000000..14815ff0c --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/mfa/challenge", () => ({ + loadActiveChallenge: vi.fn(), + recordChallengeFailure: vi.fn().mockResolvedValue({ exhausted: false }), + claimChallenge: vi.fn(), +})); + +vi.mock("@/lib/auth/mfa/webauthn", () => ({ + verifyMfaAuthentication: vi.fn(), +})); + +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { + loadActiveChallenge, + claimChallenge, + recordChallengeFailure, +} from "@/lib/auth/mfa/challenge"; +import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn"; +import { finishLogin } from "@/lib/auth/login-response"; + +function makeRequest(body: Record): NextRequest { + return new NextRequest("http://localhost/api/auth/mfa/webauthn/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const VALID_BODY = { + mfaTicket: "ticket-1", + challengeId: "ch-1", + credential: { + id: "cred-1", + rawId: "cred-1", + type: "public-key", + response: {}, + }, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + ip: "203.0.113.1", + } as never); + vi.mocked(loadActiveChallenge).mockResolvedValue({ + id: "mfa-1", + userId: "user-1", + kind: "login", + attempts: 0, + expiresAt: new Date(Date.now() + 60_000), + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + username: "u", + onboardingCompletedAt: new Date(), + } as never); + vi.mocked(recordChallengeFailure).mockResolvedValue({ + exhausted: false, + attempts: 1, + } as never); + vi.mocked(finishLogin).mockResolvedValue( + new Response(JSON.stringify({ data: { user: { id: "user-1" } } }), { + status: 200, + }), + ); +}); + +describe("POST /api/auth/mfa/webauthn/verify", () => { + it("finishes login when the assertion verifies and the ticket is claimed", async () => { + vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never); + vi.mocked(claimChallenge).mockResolvedValue(true as never); + + const res = await POST(makeRequest(VALID_BODY)); + + expect(res.status).toBe(200); + expect(verifyMfaAuthentication).toHaveBeenCalledWith( + "ch-1", + "user-1", + VALID_BODY.credential, + ); + expect(claimChallenge).toHaveBeenCalledWith("mfa-1"); + expect(finishLogin).toHaveBeenCalledWith( + expect.objectContaining({ + mfaVerified: true, + source: "mfa.webauthn.verify", + }), + ); + }); + + it("rejects with 401 and burns an attempt when the assertion fails", async () => { + vi.mocked(verifyMfaAuthentication).mockResolvedValue(false as never); + + const res = await POST(makeRequest(VALID_BODY)); + + expect(res.status).toBe(401); + expect(recordChallengeFailure).toHaveBeenCalledWith("mfa-1"); + expect(claimChallenge).not.toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("rejects with 401 for an unknown / expired ticket", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(null as never); + + const res = await POST(makeRequest(VALID_BODY)); + + expect(res.status).toBe(401); + expect(verifyMfaAuthentication).not.toHaveBeenCalled(); + }); + + it("does not mint a session if the ticket claim is lost (concurrent)", async () => { + vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never); + vi.mocked(claimChallenge).mockResolvedValue(false as never); + + const res = await POST(makeRequest(VALID_BODY)); + + expect(res.status).toBe(401); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("returns 429 when rate limited", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + ip: "203.0.113.1", + } as never); + + const res = await POST(makeRequest(VALID_BODY)); + expect(res.status).toBe(429); + }); +}); diff --git a/src/app/api/auth/mfa/webauthn/verify/options/route.ts b/src/app/api/auth/mfa/webauthn/verify/options/route.ts new file mode 100644 index 000000000..aeff3e827 --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/verify/options/route.ts @@ -0,0 +1,77 @@ +/** + * POST /api/auth/mfa/webauthn/verify/options + * + * Begin a mid-login second-factor security-key assertion. The caller presents + * the opaque `mfaTicket` from the `meta.mfaRequired` login response; the server + * resolves the password-identified user and returns assertion options scoped to + * that user's registered MFA security keys (never an empty allow-list — a + * non-resident credential is not discoverable). + * + * Anonymous surface (no session yet), rate-limited per IP. A single generic + * 401 covers every unknown / expired / consumed ticket so a guesser learns + * nothing. + */ +import { NextRequest } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { loadActiveChallenge } from "@/lib/auth/mfa/challenge"; +import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn"; +import { mfaWebauthnLoginOptionsSchema } 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, + ); + 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 = mfaWebauthnLoginOptionsSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + + const challenge = await loadActiveChallenge(parsed.data.mfaTicket); + if (!challenge) { + annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } }); + return apiError("Invalid or expired challenge", 401); + } + + // Only a login-issued challenge may begin a login assertion here. Reject any + // other challenge kind 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 result = await createMfaAuthenticationOptions(challenge.userId); + if (!result) { + // No registered security key for this account — the client should fall + // back to a TOTP / recovery code. + annotate({ action: { name: "auth.mfa.webauthn.no_key" } }); + return apiError("No security key registered", 409); + } + + annotate({ action: { name: "auth.mfa.webauthn.verify-options" } }); + return apiSuccess({ + options: result.options, + challengeId: result.challengeId, + }); +}); diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts new file mode 100644 index 000000000..3ad7c0b57 --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -0,0 +1,148 @@ +/** + * POST /api/auth/mfa/webauthn/verify + * + * Complete a mid-login second-factor login challenge with a WebAuthn security + * key. The caller presents the login `mfaTicket` plus the assertion from + * /verify/options. On success the SAME session/token bundle the password path + * issues is returned (web → cookie; native → access+refresh), with the session + * stamped `mfaVerifiedAt`. + * + * Security invariants mirror /api/auth/mfa/verify (the TOTP path): + * - No session/token exists until the assertion verifies — the partial state + * lives in the ticket, never in a half-built session. + * - The ticket is claimed once, atomically, only AFTER the assertion verifies. + * - A failed assertion increments the ticket attempt counter and burns it at + * the cap. Anonymous surface is rate-limited per IP on top of the cap. + */ +import { NextRequest } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { apiError, safeJson } from "@/lib/api-response"; +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 { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn"; +import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa"; +import { mintTrustedDevice } from "@/lib/auth/trusted-device"; +import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint"; +import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; + +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: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = mfaWebauthnLoginVerifySchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + const { mfaTicket, challengeId, credential, rememberDevice } = parsed.data; + + const challenge = await loadActiveChallenge(mfaTicket); + if (!challenge) { + annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } }); + return apiError("Invalid or expired challenge", 401); + } + + // Only a login-issued challenge may be redeemed for a session here. A future + // step-up or non-login 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: { id: true, username: true, onboardingCompletedAt: true }, + }); + if (!user) { + await recordChallengeFailure(challenge.id); + return apiError("Invalid or expired challenge", 401); + } + + let verified = false; + try { + verified = await verifyMfaAuthentication(challengeId, user.id, credential); + } catch { + // A malformed / expired WebAuthn challenge is a failed attempt, not a 500. + verified = false; + } + + if (!verified) { + const { exhausted } = await recordChallengeFailure(challenge.id); + await auditLog("auth.mfa.failed", { + userId: user.id, + ipAddress: ip, + details: { stage: "login", method: "webauthn", exhausted }, + }); + annotate({ + action: { name: "auth.mfa.failed" }, + meta: { method: "webauthn", exhausted }, + }); + return apiError("Security key verification failed", 401); + } + + // Assertion verified — claim the ticket atomically. Only the single winning + // caller proceeds to mint a session. + 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: "webauthn" }, + }); + annotate({ + action: { name: "auth.mfa.verified" }, + meta: { method: "webauthn" }, + }); + + const ua = request.headers.get("user-agent"); + + // The user just completed a second factor — clear any forced-enrollment hint. + await setMfaEnrollCookie(false); + + // v1.23 — "remember this device": skip the second factor on this browser for + // 30 days. Set before finishLogin so both cookies share the response. + if (rememberDevice) { + await mintTrustedDevice(user.id, coarseDeviceLabel(ua)); + } + + return finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "mfa.webauthn.verify", + mfaVerified: true, + }); +}); diff --git a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts index c2b1b1b8c..4850a7840 100644 --- a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts +++ b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts @@ -22,6 +22,14 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/login-alert", () => ({ + recordSignInDevice: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), // v1.4.43 W13 M-4 — passkey/login-verify now uses the auth-surface @@ -53,6 +61,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { verifyAuthentication } from "@/lib/auth/passkey"; +import { createSession } from "@/lib/auth/session"; import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; const FAKE_USER = { @@ -133,4 +142,14 @@ describe("POST /api/auth/passkey/login-verify — native token issuance", () => expect(res.status).toBe(401); expect(prisma.apiToken.create).not.toHaveBeenCalled(); }); + + // v1.23 (M-review M1) — a primary passkey login is a satisfied second factor. + it("stamps mfaVerifiedAt on the session so the passkey satisfies step-up", async () => { + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + // createSession(userId, onboardingPending, ip, ua, mfaVerifiedAt) — the + // 5th argument must be a Date for a primary passkey sign-in. + const args = vi.mocked(createSession).mock.calls[0]; + expect(args[4]).toBeInstanceOf(Date); + }); }); diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 18178dd64..6f3f68374 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -1,5 +1,7 @@ import { verifyAuthentication } from "@/lib/auth/passkey"; import { createSession } from "@/lib/auth/session"; +import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; +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"; @@ -68,14 +70,37 @@ export const POST = apiHandler(async (request: NextRequest) => { // `hl_onboarding` cookie itself; pass the user's onboarding state // through so the proxy short-circuits the redirect before // hydration. + // + // v1.23 (M-review M1) — stamp `mfaVerifiedAt` on a primary passkey login. A + // discoverable passkey is a phishing-resistant possession factor that already + // cleared user verification (`userVerification: 'required'` on the ceremony), + // so a passwordless passkey sign-in is itself a satisfied second factor: it + // meets an MFA-enforcement policy AND passes `requireFreshMfa` step-up, + // exactly like a completed password+TOTP login. (The password+TOTP path is + // unchanged — it stamps the session at `/api/auth/mfa/verify`.) const ua = request.headers.get("user-agent"); - await createSession(user.id, user.onboardingCompletedAt == null, ip, ua); + await createSession( + user.id, + user.onboardingCompletedAt == null, + ip, + ua, + new Date(), + ); + + // v1.23 — sync the admin-enforced-MFA forced-enrollment hint cookie. + await syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }); await auditLog("auth.login.passkey", { userId: user.id, 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/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index 5b0417864..4c9aadf2f 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -1,9 +1,62 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { webauthnKeyNameSchema } from "@/lib/validations/mfa"; +import { z } from "zod/v4"; + +const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema }); + +export const PATCH = apiHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) => { + const { user } = await requireAuth(); + const { id } = await params; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = passkeyRenameSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + + const passkey = await prisma.passkey.findUnique({ + where: { id }, + select: { userId: true }, + }); + if (!passkey || passkey.userId !== user.id) { + return apiError("Passkey not found", 404); + } + + const updated = await prisma.passkey.update({ + where: { id }, + data: { name: parsed.data.name }, + select: { + id: true, + name: true, + credentialDeviceType: true, + credentialBackedUp: true, + createdAt: true, + lastUsedAt: true, + }, + }); + + annotate({ action: { name: "auth.passkey.rename" } }); + return apiSuccess(updated); + }, +); export const DELETE = apiHandler( async ( diff --git a/src/app/api/auth/passkeys/route.ts b/src/app/api/auth/passkeys/route.ts index 6c2a7d497..dfebfed0c 100644 --- a/src/app/api/auth/passkeys/route.ts +++ b/src/app/api/auth/passkeys/route.ts @@ -16,6 +16,7 @@ export const GET = apiHandler(async () => { credentialDeviceType: true, credentialBackedUp: true, createdAt: true, + lastUsedAt: true, }, orderBy: { createdAt: "desc" }, }); 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/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/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/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts new file mode 100644 index 000000000..ca3e57174 --- /dev/null +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -0,0 +1,153 @@ +/** + * 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() }, + webauthnMfaCredential: { count: 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); + // Default: no registered security key. Tests that exercise the + // WebAuthn-only cohort override this. + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 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("blocks a WebAuthn-only account (no TOTP) without a fresh second factor (401 step-up)", async () => { + // No confirmed TOTP, but a registered security key — the account is still + // MFA-enrolled and must clear step-up before exporting the whole record. + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: null }, + session: { id: "sess-1" }, + } as never); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(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 a WebAuthn-only account with a fresh second factor", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: null }, + session: { id: "sess-1" }, + } as never); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(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..99193bf76 --- /dev/null +++ b/src/app/api/export/encrypted/route.ts @@ -0,0 +1,126 @@ +/** + * 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 any second factor enrolled (TOTP OR a + * security key), this is gated by `requireFreshMfaIfEnrolled` — 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, + requireFreshMfaIfEnrolled, + 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 (cookie or Bearer), then escalate to a fresh second + // factor when the account has one enrolled. `requireFreshMfaIfEnrolled` + // covers BOTH cohorts — a confirmed TOTP secret AND a registered WebAuthn + // security key — so a security-key-only account is gated too. A single-factor + // account cannot produce a fresh-MFA proof, so it passes straight through + // rather than being locked out of its own encrypted export. + const auth = await requireFreshMfaIfEnrolled(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 286e84282..aad5fd565 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -21,8 +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 { 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) => { @@ -34,123 +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, - notes: 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/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/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/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..4354eb3c1 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"; @@ -45,7 +46,7 @@ export const GET = apiHandler( }, }); - return apiSuccess(measurement); + return apiSuccess(shapeMeasurementNotes(measurement)); }, ); @@ -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/settings/account/route.ts b/src/app/api/settings/account/route.ts index d5f0d3da3..1b6125a76 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 { @@ -57,12 +66,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/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/app/api/settings/privacy-summary/route.ts b/src/app/api/settings/privacy-summary/route.ts new file mode 100644 index 000000000..8bc58c600 --- /dev/null +++ b/src/app/api/settings/privacy-summary/route.ts @@ -0,0 +1,51 @@ +/** + * GET /api/settings/privacy-summary + * + * Server-authoritative facts for the user-facing "Data & Privacy" dashboard: + * the configured retention windows and a truthful encryption-at-rest summary + * driven from the canonical `ENCRYPTED_COLUMNS` registry. No secrets, no key + * material, no per-row data — just the numbers the dashboard renders read-only. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns"; + +export const dynamic = "force-dynamic"; + +/** + * Retention windows. These mirror the defaults + env names the cleanup jobs + * read (`coach-message-cleanup.ts`, `audit-log-cleanup.ts`, the reminder + * cleanup handlers); read here directly so the dashboard never imports a + * pg-boss job module. Off-host backup retention is operator infrastructure + * (not an app setting), so it is disclosed as prose, not a number. + */ +function resolveRetention() { + const intEnv = (name: string, fallback: number, min: number): number => { + const raw = process.env[name]; + const parsed = raw ? Number.parseInt(raw, 10) : NaN; + return Number.isFinite(parsed) && parsed >= min ? parsed : fallback; + }; + return { + coachMessagesDays: intEnv("COACH_MESSAGE_RETENTION_DAYS", 365, 30), + auditLogDays: intEnv("AUDIT_LOG_RETENTION_DAYS", 365, 7), + // The push-attempt + mood-dispatch ledgers are a fixed 90-day window. + deliveryLogDays: 90, + }; +} + +export const GET = apiHandler(async () => { + await requireAuth(); + annotate({ action: { name: "settings.privacy.summary" } }); + + const encryptedModels = new Set(ENCRYPTED_COLUMNS.map((c) => c.model)); + + return apiSuccess({ + retention: resolveRetention(), + encryption: { + algorithm: "AES-256-GCM", + columnCount: ENCRYPTED_COLUMNS.length, + modelCount: encryptedModels.size, + }, + }); +}); 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/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index af70c3348..f0ddea12a 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useId, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { KeyRound, Lock, Loader2 } from "lucide-react"; @@ -13,7 +13,13 @@ import { Separator } from "@/components/ui/separator"; import { describePasskeyError } from "@/lib/passkey-errors"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { + ApiError, + apiFetchEnvelope, + apiGet, + apiPost, +} from "@/lib/api/api-fetch"; +import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; @@ -27,6 +33,15 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + // v1.23 — second step of login when the password response carries + // `meta.mfaRequired`. Holds the opaque ticket + the factors the account can + // complete the challenge with. + const [mfaChallenge, setMfaChallenge] = useState<{ + ticket: string; + methods: MfaMethod[]; + } | null>(null); + // Guards the conditional-UI passkey autofill so it starts at most once. + const autofillStarted = useRef(false); // v1.4.27 MB3 — explicit error-region id so the email + password // inputs reference the banner via `aria-describedby`. Screen readers // pair the error with the field instead of announcing it as a @@ -112,7 +127,28 @@ export default function LoginPage() { setLoading(true); try { - await apiPost("/api/auth/login", { email, password }); + // Read the envelope `meta` directly: an MFA account gets HTTP 200 with + // `data: null` + `meta.mfaRequired`, not an error and not a session. + const { meta } = await apiFetchEnvelope< + unknown, + { + mfaRequired?: boolean; + mfaTicket?: string; + methods?: MfaMethod[]; + } + >("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (meta?.mfaRequired && meta.mfaTicket) { + setMfaChallenge({ + ticket: meta.mfaTicket, + methods: meta.methods ?? ["totp", "recovery"], + }); + return; + } await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); navigateAfterLogin(); @@ -127,6 +163,50 @@ export default function LoginPage() { } } + // v1.23 — passkey conditional-UI autofill. When the browser supports it, + // arm a discoverable-credential assertion bound to the username field so a + // returning user can pick their passkey straight from the autofill prompt. + // Best-effort: any abort (the user types a password instead) is swallowed. + useEffect(() => { + if (autofillStarted.current) return; + autofillStarted.current = true; + let cancelled = false; + + (async () => { + try { + const { browserSupportsWebAuthnAutofill, startAuthentication } = + await import("@simplewebauthn/browser"); + if (!(await browserSupportsWebAuthnAutofill())) return; + + const { options, challengeId } = await apiPost<{ + options: Parameters[0]["optionsJSON"]; + challengeId: string; + }>("/api/auth/passkey/login-options"); + + const credential = await startAuthentication({ + optionsJSON: options, + useBrowserAutofill: true, + }); + if (cancelled) return; + + await apiPost("/api/auth/passkey/login-verify", { + challengeId, + credential, + }); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); + navigateAfterLogin(); + } catch { + // Autofill aborts (no passkey, user dismisses, password path taken) + // are normal — stay silent and let the explicit buttons drive login. + } + })(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the @@ -143,131 +223,152 @@ export default function LoginPage() {
-
- {/* Phase A5 / B-mobile: bumped from default size (h-9, 36px) + {mfaChallenge ? ( +
+ { + await queryClient.invalidateQueries({ + queryKey: queryKeys.auth(), + }); + navigateAfterLogin(); + }} + onCancel={() => { + setMfaChallenge(null); + setError(null); + }} + /> +
+ ) : ( +
+ {/* Phase A5 / B-mobile: bumped from default size (h-9, 36px) to size="lg" so the login CTAs meet WCAG 2.5.5 (44px minimum) on mobile. Login is the most-tapped flow on a fresh install. */} - - -
- - - {t("common.or")} - - -
- - {mode === "passkey" ? ( - ) : ( -
-
- - setEmail(e.target.value)} - required - autoComplete="username" - inputMode="email" - enterKeyHint="next" - autoCapitalize="none" - spellCheck={false} - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - placeholder={t("auth.emailOrUsernamePlaceholder")} - /> -
-
- - setPassword(e.target.value)} - required - autoComplete="current-password" - enterKeyHint="go" - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - placeholder="********" - /> -
+ +
+ + + {t("common.or")} + + +
+ + {mode === "passkey" ? ( - -
- )} + ) : ( +
+
+ + setEmail(e.target.value)} + required + // `webauthn` token arms the conditional-UI passkey + // autofill prompt on this field (see the mount effect). + autoComplete="username webauthn" + inputMode="email" + enterKeyHint="next" + autoCapitalize="none" + spellCheck={false} + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} + placeholder={t("auth.emailOrUsernamePlaceholder")} + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + enterKeyHint="go" + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} + placeholder="********" + /> +
+ + +
+ )} - {error && ( - - )} - - {registrationEnabled === true && ( -

- {t("auth.noAccount")}{" "} - - {t("auth.register")} - -

- )} -
+ {error} +
+ )} + + {registrationEnabled === true && ( +

+ {t("auth.noAccount")}{" "} + + {t("auth.register")} + +

+ )} + + )} {/* v1.4.26 — discoverable privacy link below the login card. App Store reviewers and first-time visitors need a one-click 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} - /> -
-
- -
- -