Merge pull request #1300 from MBombeck/release/v1.46.2

Release v1.46.2
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-29 12:11:46 +01:00
commit f17ad1f7b3
180 files changed
+10298 -1516

No files matched your search

+5
View File
@@ -30,6 +30,11 @@ scripts/bench/results
# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly.
tests/integration/__golden__
# A release's backup file exactly as its exporter wrote it; the golden-backup
# test checks the bytes against the manifest's SHA-256, so it is never
# reformatted. The manifests are written by the same test.
tests/fixtures/golden-backups/*.json
# inlang writes its working files (cache, metadata, README) next to the
# project settings when its CLI or SDK runs; only settings.json is tracked.
project.inlang/*
+77
View File
@@ -1,5 +1,82 @@
# Changelog
## [1.46.2] — 2026-10-29
### Fixed
- **Large accounts get their summaries.** On an account with tens of
millions of heart-rate readings the job that rebuilds the summary tier
after an upgrade ran one aggregate over five years under the database's
60 second limit, never finished, and left the dashboard cards reading the
raw table until they timed out too. It now works through the history one
calendar year at a time, every step gets five minutes of its own, and a
run that is out of time hands the remaining years to a follow-up instead
of starting over. Finding the accounts that need the rebuild no longer
reads every reading. If you raised `DATABASE_STATEMENT_TIMEOUT_MS` as a
workaround, you can set it back. (#1298)
- **Lab values from a document land on the day of the report.** A report
usually prints the collection date once, in its header, so confirming it
filed every undated value under the day you confirmed it. An undated
value now takes the report date, or the filing date when the report
names none, and an impossible date such as 30 February counts as no date.
Values filed under the wrong day before are moved once after the update:
only those that came from a confirmed document, had no date of their own,
still sit at the moment they were confirmed, and whose document names a
different day. The server log says how many moved. (#1293)
- **Deleting a blood pressure reading deletes all of it.** Deleting the
systolic or the diastolic value used to leave the other half and the
pulse behind. It now removes the whole reading, Undo brings the whole
reading back, and deleting only the pulse still removes only the pulse.
- **Daily briefings work with models that always reason.** Background work
asks for medium reasoning at most, and a model that cannot be told to
stop thinking used all of that room and returned nothing. Once HealthLog
has seen a model answer with nothing but its reasoning, background work
gives it the room the Coach gets on High. (#1276)
- **Single sign-on accounts can confirm it's you.** Creating a token,
connecting an assistant or resetting a password asks for a fresh
confirmation, and an account with no password, passkey or second factor
had nothing to offer. It now confirms through its identity provider with
a fresh login, accepted only for the linked identity and a sign-in from
the last few minutes. The same confirmation can add a passkey. (#1296)
- **A personal baseline no longer depends on the wall clock.** Reads that
ask for a baseline as of a given moment saw a different window through
the summary tier than through the raw readings.
- Signed out, the language switch keeps your choice in the browser instead
of asking the server to store it.
- A new account is greeted with a welcome instead of "welcome back".
- The Coach chat, the document chat and the profile settings accept every
language the app ships.
- The French translation says "vous" throughout.
### Added
- **Admins hear about a background job that keeps failing.** After three
failed runs in a row every admin gets a push on their system alert
channels, a reminder at most once a day while it keeps failing, and one
message when it works again. Retries of one run count once, and a job
that runs per account counts per account, so one account in trouble is
reported once and not every hour. (#1298)
- **Heart rate from Google Health and Oura is folded like Apple Health's.**
Readings older than 90 days become one value per hour, and a later sync
does not bring the folded readings back. The first fold of a large
history works through the backlog over a few nights and waits until that
account's summaries are rebuilt; afterwards the index rebuild under
Admin, System status gives the space back. (#1297)
- **Admins can delete another account** from the users list, through the
same grace window as deleting your own. Your own account, the last admin
and managed profiles are excluded.
- Crisis lines for the Netherlands and Belgium on the support card.
- A section on large accounts in the self-hosting guide.
- Tests that restore every release's own backup file, restore the backup
of an erased account, and hold the header height in every app language.
### Thanks
@Cnote43 for #1297 and #1298, written up so precisely that the fix mostly
followed the report. @haritowa for finding, fixing and live-testing the lab
date bug (#1293) and for the Codex model checks and tests (#1288). @Tio61
for testing every step of #1276. @sposschip for #1296.
## [1.46.1] — 2026-10-26
### Fixed
+160 -30
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.46.1
version: 1.46.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -2481,10 +2481,13 @@ paths:
The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`,
`webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second
factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422
`auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also
refreshes the session's second-factor stamp, exactly as signing in again would. Assertions begin at POST
/api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.
factor; on one without, `password` or `passkey`. An account without a second factor whose identity is linked to
the configured identity provider is also offered `oidc` in `meta.methods`; that proof is not posted here but
given by the browser round trip GET /api/auth/oidc/reproof, which stamps the session exactly as a password proof
does. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`,
before any guess is spent. A second-factor or passkey proof also refreshes the session's second-factor stamp,
exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method:
"passkey" | "webauthn" }`.
Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that
@@ -4077,7 +4080,9 @@ paths:
**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a
password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options
issued. On an account that already has a second factor a password is refused before it is checked, with 422
issued. An account confirmed at its identity provider (GET /api/auth/oidc/reproof, within five minutes) sends `{
method: "oidc" }` instead, which takes that re-proof as the proof and, like a password, clears the second-factor
stamp. On an account that already has a second factor a password is refused before it is checked, with 422
`auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone
would sign in without the second factor from then on.
@@ -4103,6 +4108,7 @@ paths:
schema:
anyOf:
- $ref: "#/components/schemas/PasskeyReauthRequest"
- $ref: "#/components/schemas/PasskeyProviderProofRequest"
- $ref: "#/components/schemas/StepUpMintRequest"
responses:
"200":
@@ -4825,7 +4831,10 @@ paths:
tags:
- Measurements
summary: Delete a measurement
description: Soft-deletes the row (tombstone) so paired clients can reconcile the deletion.
description: "Soft-deletes the row (tombstone) so paired clients can reconcile the deletion. A blood-pressure systolic
or diastolic takes the rest of its reading with it: the other half and the pulse with the same source and the
identical `measuredAt`, tombstoned in the same transaction with the same `deletedAt`, each with its own
`syncVersion` bump. A pulse is deleted on its own."
parameters:
- in: path
name: id
@@ -4936,7 +4945,8 @@ paths:
description: Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the
single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited
`measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is
the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.
the count of rows actually tombstoned, which includes the rest of any blood-pressure reading a selected systolic
or diastolic belongs to (as on the single DELETE). The deletions surface in `/api/sync/changes` as tombstones.
parameters:
- *a6
requestBody:
@@ -4965,7 +4975,10 @@ paths:
description: Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo
counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited
`measurements:restore:<userId>`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the
count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts.
count of rows actually un-tombstoned. A blood-pressure systolic or diastolic also restores the rows of its
reading that were deleted with it (same source, identical `measuredAt` and `deletedAt`), so naming one half
undoes the whole reading; a row deleted at another moment stays deleted. The restored rows re-surface in
`/api/sync/changes` as upserts.
parameters:
- *a6
requestBody:
@@ -13210,12 +13223,19 @@ paths:
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
responses:
"200":
description: Assessment envelope (fresh, cached, or preparing).
@@ -13242,12 +13262,19 @@ paths:
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
responses:
"200":
description: Assessment envelope (fresh, cached, or preparing).
@@ -13274,12 +13301,19 @@ paths:
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
responses:
"200":
description: Assessment envelope (fresh, cached, or preparing).
@@ -13306,12 +13340,19 @@ paths:
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
responses:
"200":
description: Assessment envelope (fresh, cached, or preparing).
@@ -13338,12 +13379,19 @@ paths:
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
responses:
"200":
description: Assessment envelope (fresh, cached, or preparing).
@@ -13371,12 +13419,19 @@ paths:
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
responses:
"200":
description: Compliance assessment envelope (fresh, cached, or preparing).
@@ -13466,12 +13521,19 @@ paths:
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
responses:
"200":
description: Assessment envelope (fresh, cached, or preparing).
@@ -13511,12 +13573,19 @@ paths:
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
responses:
"200":
description: Assessment envelope (fresh, cached, or preparing).
@@ -28533,6 +28602,13 @@ components:
enum:
- de
- en
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
- type: "null"
timezone:
type: string
@@ -28611,6 +28687,17 @@ components:
description: The re-proof arm of register-options. Recognised ONLY when `method` is the body's single key — adding any
other property routes the request to the enrollment arm, where it will be read as a factor proof and almost
certainly refused.
PasskeyProviderProofRequest:
type: object
properties:
method:
type: string
const: oidc
required:
- method
description: Take the session's recent identity-provider re-proof (GET /api/auth/oidc/reproof) as the proof. Accepted
only for an account whose `proofMethods` list `oidc` and only within five minutes of that re-proof; otherwise
401 `auth.reproof.required` with `meta.methods`. Web only.
PasskeyRegisterVerifyRequest:
type: object
properties:
@@ -31347,8 +31434,15 @@ components:
locale:
type: string
enum:
- en
- de
- en
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
scope:
type: object
properties:
@@ -31511,8 +31605,15 @@ components:
locale:
type: string
enum:
- en
- de
- en
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
attachmentIds:
minItems: 1
maxItems: 5
@@ -35502,8 +35603,15 @@ components:
locale:
type: string
enum:
- en
- de
- en
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
required:
- message
InboundFactEditRequest:
@@ -38526,8 +38634,9 @@ components:
`upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each
`/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a
credential of its own carries the generic `http.unauthenticated`. account —
account.deletion.no_guardian, account.deletion.not_pending; activity — activity.calendar.future_date,
activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, ai.record.notPermitted,
account.deletion.already_pending, account.deletion.no_guardian, account.deletion.not_pending; activity —
activity.calendar.future_date, activity.calendar.too_old; admin — admin.users.delete.managed_profile,
admin.users.delete.self; ai — ai.budget.exceeded, ai.provider.none, ai.record.notPermitted,
ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, ai.usage.invalid,
ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields;
aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.readerBusy, aiRuns.timedOut, aiRuns.workerUnavailable;
@@ -47232,11 +47341,25 @@ components:
$ref: "#/components/schemas/WebauthnMfaCredentialInfo"
passkeyNudgeDismissed:
type: boolean
proofMethods:
type: array
items:
type: string
enum:
- password
- oidc
- totp
- webauthn
- passkey
description: The ways this account can confirm it is you, the same list a recent-proof refusal names in `meta.methods`.
`password` and `oidc` only on an account without a second factor; `oidc` is a browser round trip to the
identity provider (`GET /api/auth/oidc/reproof`) and is never offered to a token.
required:
- totp
- recoveryCodesRemaining
- webauthn
- passkeyNudgeDismissed
- proofMethods
additionalProperties: false
WebauthnMfaCredentialInfo:
type: object
@@ -86330,6 +86453,12 @@ components:
type: string
username:
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)))$
description: When the signed-in account was created. The web greets an account in its first day as new rather than
welcoming it back.
timezone:
type: string
description: "The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the
@@ -86405,6 +86534,7 @@ components:
required:
- id
- username
- createdAt
- timezone
- glucoseUnit
- features
+6 -4
View File
@@ -631,10 +631,10 @@ Confirmed-rejected slugs (despite the source-tree references in the table above)
Confirmed-accepted slugs on a ChatGPT Plus subscription (per `available_in_plans` in `models.json`):
- `gpt-5.6-terra` — HealthLog's default (first chain entry) as of 2026-10-24; the backend rejected `gpt-5.5` and everything below it that day. `gpt-5.6-luna` and `gpt-5.6-sol` are its siblings on the same line
- `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-astra` — the gpt-6 line, listed in `models.json` since 2026-09-22 (`gpt-6.1-sol` the catalog default since 2026-09-29). The catalog names `gpt-6-sol` as the upgrade of `gpt-5.6-terra` and `gpt-5.6-sol`, and `gpt-6-luna` as that of `gpt-5.6-luna`. Every gpt-6 slug lists `input_modalities: ["text", "image"]` and the same reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra also `ultra`). The presets omit `none`, as they do for `gpt-5.5`, which a live probe on 2026-10-06 showed accepts it; HealthLog sends `none` for "off" on the gpt-6 line too and learns a refusal per slug. The gpt-6 slugs were not probed against a live backend when they joined the chain
- `gpt-5.5` — the default from 2026-06 until 2026-10-24
- `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — accepted per plan
- `gpt-5.3-codex` — was the default (verified 2026-05-09); the backend rotated off the `-codex` line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung
- `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-astra` — the gpt-6 line, listed in `models.json` since 2026-09-22 (`gpt-6.1-sol` the catalog default since 2026-09-29). The catalog names `gpt-6-sol` as the upgrade of `gpt-5.6-terra` and `gpt-5.6-sol`, and `gpt-6-luna` as that of `gpt-5.6-luna`. Every gpt-6 slug lists `input_modalities: ["text", "image"]` and the same reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra also `ultra`). The presets omit `none`, as they do for `gpt-5.5`, which a live probe on 2026-10-06 showed accepts it; HealthLog sends `none` for "off" on the gpt-6 line too and learns a refusal per slug. A live check on 2026-10-24 found the account's own list (`GET /backend-api/codex/models?client_version=…`) offering `gpt-6.1-sol`, `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna` and the `gpt-5.6` line, all with `input_modalities: ["text", "image"]`. From a HealthLog container with its stored sign-in, `gpt-6.1-sol`, `gpt-6-sol` and `gpt-6-luna` each answered text and read a lab-report image correctly. `gpt-6.1-sol` refuses `reasoning.effort: "none"` and names `low` as its lowest effort, which the client learns and steps up to on the same slug; the other two accept `none`
- `gpt-5.5` — the default from 2026-06 until 2026-10-24, kept as the last chain rung while plans differ
Retired for ChatGPT sign-in (OpenAI's Codex models page): `gpt-5.2` and `gpt-5.3-codex` on 2026-05-26, `gpt-5.4` and `gpt-5.4-mini` on 2026-08-31, `gpt-5.5` on 2026-10-14 at 19:00 UTC. The backend answers each of them with 400 `model_not_found`, "The '<slug>' model is not supported when using Codex with a ChatGPT account.", which the client reads as a reason to try the next slug.
The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _migration prompts_ shown to existing users — they are NOT the slugs sent on the wire after migration. The wire slugs come from `models.json`. Always cross-check `models.json` (`gh api repos/openai/codex/contents/codex-rs/models-manager/models.json`) before picking a default.
@@ -662,6 +662,8 @@ Every default rung must be a model family the reasoning, sampling and vision gat
The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. Reading the account's live list (`GET /backend-api/codex/models`, as the Codex CLI does) would avoid this.
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
**Trigger condition** (when to walk to the next slug):
+65
View File
@@ -250,3 +250,68 @@ The implementation lives in `src/lib/db.ts → getConnectionBudget()`
(split into `getPrismaPoolMax()` and `getPgBossPoolMax()`); the
20-slot default was chosen after measuring on a 4-CPU production
container — see the inline rationale in `src/lib/db.ts`.
## Large accounts
An account that holds tens of millions of readings, most often a
full-history Google Health backfill of minute-by-minute heart rate, runs
into the per-statement limit the app sets on every database connection:
60 seconds, from `DATABASE_STATEMENT_TIMEOUT_MS` (unset means 60000, `0`
turns the limit off).
### What it looks like
- Dashboard cards stay empty, or `/api/daily/digest` and other reads
answer 500.
- The log shows `job.run.failed` for `rollup-full-backfill` with
`consecutiveFailures` counting up, and Postgres error `57014`:
`canceling statement due to statement timeout`.
- The admin status page lists `rollup-full-backfill` under failing
background jobs, and every admin gets a push once a system job has
failed three jobs in a row (retries of one job count once; a job that runs
per account counts per account), a reminder at most once a day while it
keeps failing, and a notice when the failing job runs again. A one-off job
that failed and does not run again is announced once.
### What the app does about it
- The full rollup backfill, which runs at boot for any account whose
summary tier is incomplete (and after a release that adds a column to it),
folds the five-year window one calendar year at a time. Every statement of
it runs under its own limit of five minutes, whatever
`DATABASE_STATEMENT_TIMEOUT_MS` says, and a run that reaches its time budget
hands the remaining years to a follow-up job. The readers that fall back to
the raw readings while that tier is incomplete are fast again once it has
finished.
- Heart rate older than 90 days is folded into one mean per hour, for Apple
Health, Health Connect, Google Health and Oura alike. The DAY summaries keep
the figures taken from the raw samples, and a later backfill or full sync
does not write the folded samples back. On an account with a dense history
the first fold works through the backlog over a few nights, one day per
transaction, and leaves an account alone while its summary tier is still
being rebuilt. Postgres reuses the freed table pages for new rows; to give
the index space back to the disk, run the index rebuild of the measurement
maintenance job from Admin, System status, once the backlog is through
(`docs/ops/measurement-maintenance.md`).
### When to raise the limit
Normally never: the long background passes bring their own limits. Raise
`DATABASE_STATEMENT_TIMEOUT_MS` only when request reads still time out on a
large account after the backfill has finished (the failing-jobs card is
empty), or when something between the app and Postgres sets a lower limit.
In that case also check `SHOW statement_timeout;` as the database role
HealthLog connects with: a limit set on the role or the database, or by a
pooler, applies too. Recreate the container after changing the variable so
every connection picks it up:
```yaml
# docker-compose.yml
services:
app:
environment:
DATABASE_STATEMENT_TIMEOUT_MS: "180000" # three minutes
```
A higher limit lets one slow statement hold a pool connection longer, so
raise it in steps rather than turning it off.
+161
View File
@@ -0,0 +1,161 @@
/**
* The app header keeps its height in every language.
*
* A translated label that is a little longer than the English one wraps, and
* a header that wraps grows: the dashboard's title row once took a second
* line in one locale while every English check stayed green. The top bar and
* the dashboard's page-header row are measured here in every shipped locale,
* on a 390 px phone and on a desktop window, against the height English gives
* them on the same viewport.
*
* The language is switched the way the app switches it, through
* `PUT /api/auth/me/locale`, which writes the account's language and the
* locale cookie; `<html lang>` is then asserted before anything is measured,
* so a switch that silently did not happen cannot pass as "same height".
*
* The locale list is the app's own (`src/lib/i18n/config.ts`), so a language
* added there is swept without touching this file.
*/
import type { Page } from "@playwright/test";
import { locales, type Locale } from "@/lib/i18n/config";
import { HEADER_LOCALES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
const VIEWPORTS = [
{ label: "phone 390", width: 390, height: 844 },
{ label: "desktop 1280", width: 1280, height: 800 },
] as const;
type ViewportLabel = (typeof VIEWPORTS)[number]["label"];
interface HeaderGeometry {
topBar: number;
pageHeader: number;
/** For the failure message: what wrapped, not only that something did. */
title: string;
titleHeight: number;
}
/** English first: every other locale is compared with what it measured. */
const ORDER: Locale[] = ["en", ...locales.filter((l) => l !== "en")];
const reference = new Map<ViewportLabel, HeaderGeometry>();
async function readHeader(page: Page): Promise<HeaderGeometry | null> {
return page.evaluate(() => {
const topBar = document.querySelector('[data-slot="top-bar"]');
const rows = document.querySelectorAll(
'main [data-slot="page-header-row"]',
);
if (!topBar || rows.length !== 1) return null;
const row = rows[0];
const title = row.querySelector("h1");
return {
topBar: topBar.getBoundingClientRect().height,
pageHeader: row.getBoundingClientRect().height,
title: title?.textContent ?? "",
titleHeight: title?.getBoundingClientRect().height ?? 0,
};
});
}
/**
* Read the header until two reads a frame apart agree. The greeting swaps to
* the personalised line after hydration; measuring mid-swap would compare a
* frame nobody keeps.
*/
async function settledHeader(page: Page): Promise<HeaderGeometry> {
await page.evaluate(() => document.fonts.ready.then(() => undefined));
let previous: HeaderGeometry | null = null;
for (let attempt = 0; attempt < 20; attempt += 1) {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
const current = await readHeader(page);
if (
current &&
previous &&
current.topBar === previous.topBar &&
current.pageHeader === previous.pageHeader
) {
return current;
}
previous = current;
}
throw new Error(
`header did not settle: ${JSON.stringify(previous ?? "not found")}`,
);
}
test.describe("header height in every locale", () => {
test.use({ storageState: HEADER_LOCALES_STORAGE_STATE_PATH });
// The account's language is one value; the locales take turns on it.
// It runs in the desktop project only (see `playwright.config.ts`) and sets
// both viewports itself.
test.describe.configure({ mode: "serial" });
for (const locale of ORDER) {
test(`top bar and dashboard header keep the English height in ${locale}`, async ({
page,
}) => {
test.setTimeout(60_000);
const switched = await page.request.put("/api/auth/me/locale", {
data: { locale },
});
expect(switched.status(), await switched.text()).toBe(200);
for (const viewport of VIEWPORTS) {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator("html")).toHaveAttribute("lang", locale);
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
await expect(
page.locator('main [data-slot="page-header-row"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-header-greeting"]'),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.innerWidth))
.toBe(viewport.width);
const measured = await settledHeader(page);
const at = `${locale}, ${viewport.label}`;
// A band that is not there measures 0 in every locale and "agrees".
expect(
measured.topBar,
`top bar has no height (${at})`,
).toBeGreaterThan(32);
expect(
measured.pageHeader,
`page header has no height (${at})`,
).toBeGreaterThan(20);
if (locale === "en") {
reference.set(viewport.label, measured);
continue;
}
const english = reference.get(viewport.label);
expect(english, "the English reference ran first").toBeDefined();
const detail = `title "${measured.title}" ${measured.titleHeight}px tall vs "${english!.title}" ${english!.titleHeight}px`;
expect(
Math.abs(measured.topBar - english!.topBar),
`top bar is ${measured.topBar}px in ${at}, ${english!.topBar}px in English`,
).toBeLessThanOrEqual(1);
expect(
Math.abs(measured.pageHeader - english!.pageHeader),
`dashboard header is ${measured.pageHeader}px in ${at}, ${english!.pageHeader}px in English; ${detail}`,
).toBeLessThanOrEqual(1);
}
});
}
});
+49
View File
@@ -325,6 +325,25 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateScoreHistory.json",
);
/**
* The header-height sweep's own account (`header-height-locales.spec.ts`).
*
* It moves the account's language through every shipped locale; the server
* honours `User.locale` wherever a request carries no locale cookie, so on a
* shared account a parallel spec could render in Korean halfway through.
*/
export const E2E_HEADER_LOCALES = {
email: "e2e-header-locales@healthlog.test",
username: "e2e-header-locales",
password: "Hl8!Tq2vNx6mWr4Z",
role: "USER",
} as const;
export const HEADER_LOCALES_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateHeaderLocales.json",
);
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateAiOptional.json",
@@ -1102,6 +1121,33 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// The header-height sweep's account. Default modules and layout, so the
// dashboard header carries what a fresh account's does; the language is
// put back to English because the sweep leaves it on its last locale.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json, locale)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, 'en')
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = NULL,
dashboard_widgets_json = NULL,
locale = 'en'`,
[
cuid(),
E2E_HEADER_LOCALES.username,
E2E_HEADER_LOCALES.email,
await hashPassword(E2E_HEADER_LOCALES.password),
now,
],
);
// The notification-dispatch journey's account. Seeded like the others;
// its channels, devices, ledger rows and preferences are reset by
// `e2e/setup/notification-fixture.ts` before every test, because the
@@ -1784,6 +1830,9 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
// The header-height sweep's jar — see `E2E_HEADER_LOCALES`.
await capture(E2E_HEADER_LOCALES, HEADER_LOCALES_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
+35 -5
View File
@@ -789,7 +789,10 @@
"title": "Wiederherstellungs-Kit sichern",
"body": "Ohne Kopien der Schlüssel, die dieser Server nutzt, lassen sich seine Daten nach einer Neuinstallation nicht wiederherstellen. Das dauert nur ein paar Minuten.",
"action": "Kit öffnen"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Willkommen.",
"welcomeNewWithName": "Willkommen, {name}."
},
"measurements": {
"title": "Messungen",
@@ -6237,14 +6240,17 @@
"password": "Passwort",
"totp": "Authenticator-Code",
"passkey": "Passkey",
"webauthn": "Sicherheitsschlüssel"
"webauthn": "Sicherheitsschlüssel",
"oidc": "Identitätsanbieter"
},
"currentPassword": "Aktuelles Passwort",
"authenticatorCode": "Authenticator-Code",
"authenticatorPlaceholder": "Sechsstelliger Authenticator-Code",
"verifyAndAdd": "Bestätigen und hinzufügen",
"factorDescription": "Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.",
"verifyAndContinue": "Bestätigen und fortfahren"
"verifyAndContinue": "Bestätigen und fortfahren",
"oidcHint": "Du meldest dich bei deinem Identitätsanbieter noch einmal an und kommst direkt auf diese Seite zurück. Dann wiederholst du, was du gerade tun wolltest.",
"continueToProvider": "Weiter zur Anmeldung"
},
"passkeyName": "Name",
"noPasskeys": "Keine Passkeys registriert.",
@@ -7517,7 +7523,9 @@
"description": "Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.",
"descriptionChange": "Das ändert etwas, das nur du ändern können solltest. Bestätige, dass du es bist, um fortzufahren.",
"signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.",
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal.",
"oidcDone": "Bestätigt. In den nächsten fünf Minuten kannst du weitermachen, ohne erneut gefragt zu werden.",
"oidcFailed": "Dein Identitätsanbieter konnte nicht bestätigen, dass du es bist. Versuche es noch einmal."
},
"passwordTitle": "Passwort",
"tokenNameLabel": "Token-Name",
@@ -8105,7 +8113,16 @@
"undoDeletionBody": "{name} hat die Löschung dieses Kontos verlangt; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
"undoDeletionBodyGuardian": "Eine betreuende Person hat das verwaltete Profil {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Profil und alles darin so, wie es war, und die betreuenden Personen erreichen es wieder. Seine Freigabe-Links und Tokens bleiben widerrufen und müssen neu eingerichtet werden.",
"undoDeletionDone": "Löschung von {name} rückgängig gemacht",
"undoDeletionFailed": "Löschung konnte nicht rückgängig gemacht werden"
"undoDeletionFailed": "Löschung konnte nicht rückgängig gemacht werden",
"undoDeletionBodyAdmin": "Ein Admin hat das Konto {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
"deleteUser": "Konto löschen",
"deleteUserTitle": "Das Konto {name} löschen?",
"deleteUserBodyGrace": "{name} wird sofort überall abgemeldet und kann sich nicht mehr anmelden. Das Konto und alles darin wird am {date} gelöscht. Bis dahin kannst du die Löschung hier rückgängig machen.",
"deleteUserBodyNow": "{name} und alles im Konto wird sofort gelöscht. Dieser Server hat keine Frist eingestellt, die Löschung lässt sich also nicht rückgängig machen.",
"deleteUserConfirm": "Konto löschen",
"deleteUserScheduled": "Das Konto {name} ist gesperrt und wird am {date} gelöscht",
"deleteUserDone": "Das Konto {name} wurde gelöscht",
"deleteUserFailed": "Das Konto konnte nicht gelöscht werden"
},
"invites": {
"title": "Einladungen",
@@ -9243,6 +9260,13 @@
"titleFailing": "Sicherungen sind gefährdet",
"titleReminder": "Sicherungen sind weiterhin gefährdet",
"titleRecovered": "Sicherungen laufen wieder normal"
},
"systemJobAlert": {
"titleFailing": "Hintergrundjob schlägt fehl: {queue}",
"titleReminder": "Hintergrundjob schlägt weiter fehl: {queue}",
"titleRecovered": "Hintergrundjob läuft wieder: {queue}",
"bodyFailing": "Der Hintergrundjob {queue} ist {failures}-mal in Folge fehlgeschlagen und steht auf der Admin-Statusseite. Letzter Fehler: {error}",
"bodyRecovered": "Der Hintergrundjob {queue} ist wieder erfolgreich gelaufen."
}
},
"i18n": {
@@ -11250,6 +11274,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, täglich 15:30 bis 0:30 Uhr)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Niederlande, rund um die Uhr, kostenlos, anonym, auch per Chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Belgien, rund um die Uhr, kostenlos, anonym)"
}
},
"instrumentDescription": {
+35 -5
View File
@@ -789,7 +789,10 @@
"title": "Save your recovery kit",
"body": "Without copies of the keys this server uses, its data cannot be recovered after a reinstall. It takes a few minutes.",
"action": "Open the kit"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Welcome.",
"welcomeNewWithName": "Welcome, {name}."
},
"measurements": {
"title": "Measurements",
@@ -6237,14 +6240,17 @@
"password": "Password",
"totp": "Authenticator code",
"passkey": "Passkey",
"webauthn": "Security key"
"webauthn": "Security key",
"oidc": "Identity provider"
},
"currentPassword": "Current password",
"authenticatorCode": "Authenticator code",
"authenticatorPlaceholder": "6-digit authenticator code",
"verifyAndAdd": "Verify and add",
"factorDescription": "Verify an existing sign-in method before adding a second factor.",
"verifyAndContinue": "Verify and continue"
"verifyAndContinue": "Verify and continue",
"oidcHint": "You sign in once more at your identity provider and come straight back to this page. Then do what you were about to do again.",
"continueToProvider": "Continue to sign-in"
},
"passkeyName": "Name",
"noPasskeys": "No passkeys registered.",
@@ -7517,7 +7523,9 @@
"description": "This shares or exports your health data. Confirm it's you to continue.",
"descriptionChange": "This changes something only you should be able to change. Confirm it's you to continue.",
"signInAgain": "Sign in again, then try once more. Your account has no password or passkey to confirm here.",
"failed": "That did not work. Check what you entered and try again."
"failed": "That did not work. Check what you entered and try again.",
"oidcDone": "Confirmed. For the next five minutes you can go ahead without being asked again.",
"oidcFailed": "Your identity provider could not confirm it's you. Try again."
},
"passwordTitle": "Password",
"tokenNameLabel": "Token name",
@@ -8105,7 +8113,16 @@
"undoDeletionBody": "{name} asked to delete this account, and it is erased when the grace window ends. Undoing keeps the account and everything in it as it was. {name} can sign in again, but API tokens, share links and trusted devices stay revoked and have to be set up again.",
"undoDeletionBodyGuardian": "A guardian deleted the managed profile {name}, and it is erased when the grace window ends. Undoing keeps the profile and everything in it as it was, and its guardians can reach it again. Its share links and tokens stay revoked and have to be set up again.",
"undoDeletionDone": "Deletion undone for {name}",
"undoDeletionFailed": "Could not undo the deletion"
"undoDeletionFailed": "Could not undo the deletion",
"undoDeletionBodyAdmin": "An admin deleted the account {name}, and it is erased when the grace window ends. Undoing keeps the account and everything in it as it was. {name} can sign in again, but API tokens, share links and trusted devices stay revoked and have to be set up again.",
"deleteUser": "Delete account",
"deleteUserTitle": "Delete the account {name}?",
"deleteUserBodyGrace": "{name} is signed out everywhere at once and can no longer sign in. The account and everything in it is erased on {date}. Until then you can undo the deletion here.",
"deleteUserBodyNow": "{name} and everything in the account is erased right away. This server keeps no grace window, so the deletion cannot be undone.",
"deleteUserConfirm": "Delete account",
"deleteUserScheduled": "The account {name} is locked and will be erased on {date}",
"deleteUserDone": "The account {name} was deleted",
"deleteUserFailed": "Could not delete the account"
},
"invites": {
"title": "Invites",
@@ -9243,6 +9260,13 @@
"titleFailing": "Backups are at risk",
"titleReminder": "Backups are still at risk",
"titleRecovered": "Backups are back to normal"
},
"systemJobAlert": {
"titleFailing": "Background job failing: {queue}",
"titleReminder": "Background job still failing: {queue}",
"titleRecovered": "Background job working again: {queue}",
"bodyFailing": "The background job {queue} has failed {failures} times in a row and is listed on the admin status page. Last error: {error}",
"bodyRecovered": "The background job {queue} has run successfully again."
}
},
"i18n": {
@@ -11250,6 +11274,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, daily 3.30 pm to 12.30 am)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Netherlands, 24/7, free, anonymous, also by chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Belgium, 24/7, free, anonymous)"
}
},
"instrumentDescription": {
+35 -5
View File
@@ -789,7 +789,10 @@
"title": "Guarda tu kit de recuperación",
"body": "Sin copias de las claves que usa este servidor, sus datos no se pueden recuperar tras una reinstalación. Es cuestión de unos minutos.",
"action": "Abrir el kit"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Te damos la bienvenida.",
"welcomeNewWithName": "Te damos la bienvenida, {name}."
},
"measurements": {
"title": "Mediciones",
@@ -6237,14 +6240,17 @@
"password": "Contraseña",
"totp": "Código del autenticador",
"passkey": "Clave de acceso",
"webauthn": "Llave de seguridad"
"webauthn": "Llave de seguridad",
"oidc": "Proveedor de identidad"
},
"currentPassword": "Contraseña actual",
"authenticatorCode": "Código del autenticador",
"authenticatorPlaceholder": "Código de seis dígitos del autenticador",
"verifyAndAdd": "Verificar y añadir",
"factorDescription": "Verifica un método de inicio de sesión existente antes de añadir un segundo factor.",
"verifyAndContinue": "Verificar y continuar"
"verifyAndContinue": "Verificar y continuar",
"oidcHint": "Vuelves a iniciar sesión en tu proveedor de identidad y regresas directamente a esta página. Después repite lo que ibas a hacer.",
"continueToProvider": "Continuar al inicio de sesión"
},
"passkeyName": "Nombre",
"noPasskeys": "No hay claves de acceso registradas.",
@@ -7517,7 +7523,9 @@
"description": "Esto comparte o exporta tus datos de salud. Confirma que eres tú para continuar.",
"descriptionChange": "Esto cambia algo que solo tú deberías poder cambiar. Confirma que eres tú para continuar.",
"signInAgain": "Vuelve a iniciar sesión y inténtalo de nuevo. Tu cuenta no tiene contraseña ni llave de acceso que confirmar aquí.",
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo."
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo.",
"oidcDone": "Confirmado. Durante los próximos cinco minutos puedes continuar sin que se te vuelva a preguntar.",
"oidcFailed": "Tu proveedor de identidad no pudo confirmar que eres tú. Inténtalo de nuevo."
},
"passwordTitle": "Contraseña",
"tokenNameLabel": "Nombre del token",
@@ -8105,7 +8113,16 @@
"undoDeletionBody": "{name} pidió eliminar esta cuenta, y se borra cuando termina el plazo de gracia. Deshacerlo conserva la cuenta y todo su contenido tal como estaba. {name} puede volver a iniciar sesión, pero los tokens de API, los enlaces compartidos y los dispositivos de confianza siguen revocados y hay que configurarlos de nuevo.",
"undoDeletionBodyGuardian": "Una persona responsable eliminó el perfil gestionado {name}, y se borra cuando termina el plazo de gracia. Deshacerlo conserva el perfil y todo su contenido tal como estaba, y las personas responsables vuelven a tener acceso. Sus enlaces compartidos y tokens siguen revocados y hay que configurarlos de nuevo.",
"undoDeletionDone": "Eliminación de {name} deshecha",
"undoDeletionFailed": "No se pudo deshacer la eliminación"
"undoDeletionFailed": "No se pudo deshacer la eliminación",
"undoDeletionBodyAdmin": "Un administrador eliminó la cuenta {name}, y se borra cuando termina el plazo de gracia. Deshacerlo conserva la cuenta y todo su contenido tal como estaba. {name} puede volver a iniciar sesión, pero los tokens de API, los enlaces compartidos y los dispositivos de confianza siguen revocados y hay que configurarlos de nuevo.",
"deleteUser": "Eliminar cuenta",
"deleteUserTitle": "¿Eliminar la cuenta {name}?",
"deleteUserBodyGrace": "{name} cierra sesión en todas partes al instante y ya no puede iniciar sesión. La cuenta y todo su contenido se borran el {date}. Hasta entonces puedes deshacer la eliminación aquí.",
"deleteUserBodyNow": "{name} y todo el contenido de la cuenta se borran de inmediato. Este servidor no tiene plazo de gracia, así que la eliminación no se puede deshacer.",
"deleteUserConfirm": "Eliminar cuenta",
"deleteUserScheduled": "La cuenta {name} está bloqueada y se borrará el {date}",
"deleteUserDone": "Se eliminó la cuenta {name}",
"deleteUserFailed": "No se pudo eliminar la cuenta"
},
"invites": {
"title": "Invitaciones",
@@ -9243,6 +9260,13 @@
"titleFailing": "Las copias de seguridad están en riesgo",
"titleReminder": "Las copias de seguridad siguen en riesgo",
"titleRecovered": "Las copias de seguridad vuelven a la normalidad"
},
"systemJobAlert": {
"titleFailing": "Tarea en segundo plano con fallos: {queue}",
"titleReminder": "La tarea en segundo plano sigue fallando: {queue}",
"titleRecovered": "La tarea en segundo plano vuelve a funcionar: {queue}",
"bodyFailing": "La tarea en segundo plano {queue} ha fallado {failures} veces seguidas y aparece en la página de estado de administración. Último error: {error}",
"bodyRecovered": "La tarea en segundo plano {queue} se ha vuelto a ejecutar correctamente."
}
},
"i18n": {
@@ -11250,6 +11274,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, todos los días de 15:30 a 0:30)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Países Bajos, 24 horas, gratuito, anónimo, también por chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Bélgica, 24 horas, gratuito, anónimo)"
}
},
"instrumentDescription": {
+536 -506
View File
File diff suppressed because it is too large. Load diff
+35 -5
View File
@@ -789,7 +789,10 @@
"title": "Salva il tuo kit di recupero",
"body": "Senza copie delle chiavi usate da questo server, i suoi dati non si possono recuperare dopo una reinstallazione. Ci vogliono pochi minuti.",
"action": "Apri il kit"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Ti diamo il benvenuto.",
"welcomeNewWithName": "Ti diamo il benvenuto, {name}."
},
"measurements": {
"title": "Misurazioni",
@@ -6237,14 +6240,17 @@
"password": "Password",
"totp": "Codice dell’app di autenticazione",
"passkey": "Passkey",
"webauthn": "Chiave di sicurezza"
"webauthn": "Chiave di sicurezza",
"oidc": "Provider di identità"
},
"currentPassword": "Password attuale",
"authenticatorCode": "Codice dell’app di autenticazione",
"authenticatorPlaceholder": "Codice a 6 cifre dell’app di autenticazione",
"verifyAndAdd": "Verifica e aggiungi",
"factorDescription": "Verifica un metodo di accesso esistente prima di aggiungere un secondo fattore.",
"verifyAndContinue": "Verifica e continua"
"verifyAndContinue": "Verifica e continua",
"oidcHint": "Accedi di nuovo presso il tuo provider di identità e torni subito a questa pagina. Poi ripeti quello che stavi per fare.",
"continueToProvider": "Continua all'accesso"
},
"passkeyName": "Nome",
"noPasskeys": "Nessuna passkey registrata.",
@@ -7517,7 +7523,9 @@
"description": "Questa azione condivide o esporta i tuoi dati sanitari. Conferma che sei tu per continuare.",
"descriptionChange": "Questo modifica qualcosa che solo tu dovresti poter modificare. Conferma che sei tu per continuare.",
"signInAgain": "Accedi di nuovo, poi riprova. Il tuo account non ha una password o una passkey da confermare qui.",
"failed": "Non ha funzionato. Controlla quanto inserito e riprova."
"failed": "Non ha funzionato. Controlla quanto inserito e riprova.",
"oidcDone": "Confermato. Per i prossimi cinque minuti puoi procedere senza che ti venga chiesto di nuovo.",
"oidcFailed": "Il tuo provider di identità non ha potuto confermare che sei tu. Riprova."
},
"passwordTitle": "Password",
"tokenNameLabel": "Nome del token",
@@ -8105,7 +8113,16 @@
"undoDeletionBody": "{name} ha chiesto di eliminare questo account, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene l’account e tutto il suo contenuto com’era. {name} può accedere di nuovo, ma i token API, i link di condivisione e i dispositivi attendibili restano revocati e vanno configurati di nuovo.",
"undoDeletionBodyGuardian": "Una persona responsabile ha eliminato il profilo gestito {name}, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene il profilo e tutto il suo contenuto com’era, e le persone responsabili possono di nuovo accedervi. I suoi link di condivisione e token restano revocati e vanno configurati di nuovo.",
"undoDeletionDone": "Eliminazione di {name} annullata",
"undoDeletionFailed": "Impossibile annullare l’eliminazione"
"undoDeletionFailed": "Impossibile annullare l’eliminazione",
"undoDeletionBodyAdmin": "Un amministratore ha eliminato l’account {name}, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene l’account e tutto il suo contenuto com’era. {name} può accedere di nuovo, ma i token API, i link di condivisione e i dispositivi attendibili restano revocati e vanno configurati di nuovo.",
"deleteUser": "Elimina account",
"deleteUserTitle": "Eliminare l’account {name}?",
"deleteUserBodyGrace": "{name} viene disconnesso ovunque subito e non può più accedere. L’account e tutto il suo contenuto vengono cancellati il {date}. Fino ad allora puoi annullare l’eliminazione da qui.",
"deleteUserBodyNow": "{name} e tutto il contenuto dell’account vengono cancellati subito. Questo server non ha un periodo di tolleranza, quindi l’eliminazione non si può annullare.",
"deleteUserConfirm": "Elimina account",
"deleteUserScheduled": "L’account {name} è bloccato e verrà cancellato il {date}",
"deleteUserDone": "L’account {name} è stato eliminato",
"deleteUserFailed": "Impossibile eliminare l’account"
},
"invites": {
"title": "Inviti",
@@ -9243,6 +9260,13 @@
"titleFailing": "I backup sono a rischio",
"titleReminder": "I backup sono ancora a rischio",
"titleRecovered": "I backup sono tornati alla normalità"
},
"systemJobAlert": {
"titleFailing": "Attività in background non riuscita: {queue}",
"titleReminder": "Attività in background ancora non riuscita: {queue}",
"titleRecovered": "Attività in background di nuovo funzionante: {queue}",
"bodyFailing": "L'attività in background {queue} non è riuscita {failures} volte di seguito ed è elencata nella pagina di stato dell'amministrazione. Ultimo errore: {error}",
"bodyRecovered": "L'attività in background {queue} è stata di nuovo eseguita correttamente."
}
},
"i18n": {
@@ -11250,6 +11274,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portogallo, tutti i giorni dalle 15:30 alle 0:30)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Paesi Bassi, 24 ore su 24, gratuito, anonimo, anche in chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Belgio, 24 ore su 24, gratuito, anonimo)"
}
},
"instrumentDescription": {
+35 -5
View File
@@ -789,7 +789,10 @@
"title": "복구 키트를 보관하세요",
"body": "이 서버가 사용하는 키의 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 몇 분이면 됩니다.",
"action": "키트 열기"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "환영합니다.",
"welcomeNewWithName": "{name}님, 환영합니다."
},
"measurements": {
"title": "측정",
@@ -6237,14 +6240,17 @@
"password": "비밀번호",
"totp": "인증 앱 코드",
"passkey": "패스키",
"webauthn": "보안 키"
"webauthn": "보안 키",
"oidc": "ID 공급자"
},
"currentPassword": "현재 비밀번호",
"authenticatorCode": "인증 앱 코드",
"authenticatorPlaceholder": "6자리 인증 앱 코드",
"verifyAndAdd": "확인 후 추가",
"factorDescription": "두 번째 인증 수단을 추가하기 전에 기존 로그인 방법을 확인하세요.",
"verifyAndContinue": "확인하고 계속"
"verifyAndContinue": "확인하고 계속",
"oidcHint": "ID 공급자에서 한 번 더 로그인하면 바로 이 페이지로 돌아옵니다. 그런 다음 하려던 작업을 다시 진행하세요.",
"continueToProvider": "로그인으로 계속"
},
"passkeyName": "이름",
"noPasskeys": "등록된 패스키가 없어요.",
@@ -7517,7 +7523,9 @@
"description": "건강 데이터를 공유하거나 내보내는 작업입니다. 계속하려면 본인임을 확인하세요.",
"descriptionChange": "본인만 변경할 수 있어야 하는 항목을 변경합니다. 계속하려면 본인임을 확인하세요.",
"signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.",
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요."
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요.",
"oidcDone": "확인되었습니다. 앞으로 5분 동안은 다시 묻지 않고 계속할 수 있습니다.",
"oidcFailed": "ID 공급자가 본인임을 확인하지 못했습니다. 다시 시도하세요."
},
"passwordTitle": "비밀번호",
"tokenNameLabel": "토큰 이름",
@@ -8080,7 +8088,16 @@
"undoDeletionBody": "{name} 님이 이 계정의 삭제를 요청했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. {name} 님은 다시 로그인할 수 있지만, API 토큰, 공유 링크, 신뢰하는 기기는 계속 취소된 상태라 다시 설정해야 해요.",
"undoDeletionBodyGuardian": "보호자가 관리 프로필 {name}을(를) 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 프로필과 그 안의 모든 내용이 그대로 남고, 보호자들이 다시 접근할 수 있어요. 공유 링크와 토큰은 계속 취소된 상태라 다시 설정해야 해요.",
"undoDeletionDone": "{name} 삭제를 취소했어요",
"undoDeletionFailed": "삭제를 취소하지 못했어요"
"undoDeletionFailed": "삭제를 취소하지 못했어요",
"undoDeletionBodyAdmin": "관리자가 {name} 계정을 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. {name} 님은 다시 로그인할 수 있지만, API 토큰, 공유 링크, 신뢰하는 기기는 계속 취소된 상태라 다시 설정해야 해요.",
"deleteUser": "계정 삭제",
"deleteUserTitle": "{name} 계정을 삭제할까요?",
"deleteUserBodyGrace": "{name} 님은 즉시 모든 곳에서 로그아웃되고 더 이상 로그인할 수 없어요. 계정과 그 안의 모든 내용은 {date}에 지워져요. 그때까지는 여기서 삭제를 취소할 수 있어요.",
"deleteUserBodyNow": "{name} 님과 계정 안의 모든 내용이 바로 지워져요. 이 서버에는 유예 기간이 없어서 삭제를 취소할 수 없어요.",
"deleteUserConfirm": "계정 삭제",
"deleteUserScheduled": "{name} 계정이 잠겼고 {date}에 지워져요",
"deleteUserDone": "{name} 계정을 삭제했어요",
"deleteUserFailed": "계정을 삭제하지 못했어요"
},
"invites": {
"title": "초대",
@@ -9243,6 +9260,13 @@
"titleFailing": "백업이 위험해요",
"titleReminder": "백업이 여전히 위험해요",
"titleRecovered": "백업이 다시 정상이에요"
},
"systemJobAlert": {
"titleFailing": "백그라운드 작업이 실패하고 있어요: {queue}",
"titleReminder": "백그라운드 작업이 계속 실패하고 있어요: {queue}",
"titleRecovered": "백그라운드 작업이 다시 작동해요: {queue}",
"bodyFailing": "백그라운드 작업 {queue}이(가) {failures}번 연속 실패했어요. 관리자 상태 페이지에서 확인할 수 있어요. 마지막 오류: {error}",
"bodyRecovered": "백그라운드 작업 {queue}이(가) 다시 정상적으로 실행됐어요."
}
},
"i18n": {
@@ -11250,6 +11274,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (포르투갈, 매일 15:30~00:30)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (네덜란드, 24시간, 무료, 익명, 채팅 가능)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (벨기에, 24시간, 무료, 익명)"
}
},
"instrumentDescription": {
+35 -5
View File
@@ -789,7 +789,10 @@
"title": "Bewaar je herstelkit",
"body": "Zonder kopieën van de sleutels die deze server gebruikt, kunnen de gegevens na een herinstallatie niet worden hersteld. Het kost een paar minuten.",
"action": "Kit openen"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Welkom.",
"welcomeNewWithName": "Welkom, {name}."
},
"measurements": {
"title": "Metingen",
@@ -6237,14 +6240,17 @@
"password": "Wachtwoord",
"totp": "Authenticatorcode",
"passkey": "Passkey",
"webauthn": "Beveiligingssleutel"
"webauthn": "Beveiligingssleutel",
"oidc": "Identiteitsprovider"
},
"currentPassword": "Huidig wachtwoord",
"authenticatorCode": "Authenticatorcode",
"authenticatorPlaceholder": "Authenticatorcode van 6 cijfers",
"verifyAndAdd": "Verifiëren en toevoegen",
"factorDescription": "Verifieer een bestaande inlogmethode voordat je een tweede factor toevoegt.",
"verifyAndContinue": "Verifiëren en doorgaan"
"verifyAndContinue": "Verifiëren en doorgaan",
"oidcHint": "Je logt nog een keer in bij je identiteitsprovider en komt meteen terug op deze pagina. Doe daarna opnieuw wat je wilde doen.",
"continueToProvider": "Verder naar inloggen"
},
"passkeyName": "Naam",
"noPasskeys": "Geen passkeys geregistreerd.",
@@ -7517,7 +7523,9 @@
"description": "Hiermee deel of exporteer je je gezondheidsgegevens. Bevestig dat jij het bent om door te gaan.",
"descriptionChange": "Hiermee wijzig je iets wat alleen jij zou moeten kunnen wijzigen. Bevestig dat jij het bent om door te gaan.",
"signInAgain": "Log opnieuw in en probeer het daarna nog een keer. Je account heeft geen wachtwoord of passkey waarmee je hier kunt bevestigen.",
"failed": "Dat is niet gelukt. Controleer wat je hebt ingevoerd en probeer het opnieuw."
"failed": "Dat is niet gelukt. Controleer wat je hebt ingevoerd en probeer het opnieuw.",
"oidcDone": "Bevestigd. De komende vijf minuten kun je verder zonder dat het opnieuw wordt gevraagd.",
"oidcFailed": "Je identiteitsprovider kon niet bevestigen dat jij het bent. Probeer het opnieuw."
},
"passwordTitle": "Wachtwoord",
"tokenNameLabel": "Tokennaam",
@@ -8105,7 +8113,16 @@
"undoDeletionBody": "{name} heeft gevraagd dit account te verwijderen; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het account en alles erin zoals het was. {name} kan weer inloggen, maar API-tokens, deellinks en vertrouwde apparaten blijven ingetrokken en moeten opnieuw worden ingesteld.",
"undoDeletionBodyGuardian": "Een verzorger heeft het beheerde profiel {name} verwijderd; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het profiel en alles erin zoals het was, en de verzorgers kunnen het weer bereiken. De deellinks en tokens blijven ingetrokken en moeten opnieuw worden ingesteld.",
"undoDeletionDone": "Verwijdering van {name} ongedaan gemaakt",
"undoDeletionFailed": "Verwijdering kon niet ongedaan worden gemaakt"
"undoDeletionFailed": "Verwijdering kon niet ongedaan worden gemaakt",
"undoDeletionBodyAdmin": "Een beheerder heeft het account {name} verwijderd; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het account en alles erin zoals het was. {name} kan weer inloggen, maar API-tokens, deellinks en vertrouwde apparaten blijven ingetrokken en moeten opnieuw worden ingesteld.",
"deleteUser": "Account verwijderen",
"deleteUserTitle": "Het account {name} verwijderen?",
"deleteUserBodyGrace": "{name} wordt meteen overal uitgelogd en kan niet meer inloggen. Het account en alles erin wordt op {date} gewist. Tot dan kun je de verwijdering hier ongedaan maken.",
"deleteUserBodyNow": "{name} en alles in het account wordt meteen gewist. Deze server heeft geen bedenktijd, dus de verwijdering kan niet ongedaan worden gemaakt.",
"deleteUserConfirm": "Account verwijderen",
"deleteUserScheduled": "Het account {name} is vergrendeld en wordt op {date} gewist",
"deleteUserDone": "Het account {name} is verwijderd",
"deleteUserFailed": "Het account kon niet worden verwijderd"
},
"invites": {
"title": "Uitnodigingen",
@@ -9243,6 +9260,13 @@
"titleFailing": "Back-ups lopen gevaar",
"titleReminder": "Back-ups lopen nog steeds gevaar",
"titleRecovered": "Back-ups zijn weer in orde"
},
"systemJobAlert": {
"titleFailing": "Achtergrondtaak mislukt: {queue}",
"titleReminder": "Achtergrondtaak mislukt nog steeds: {queue}",
"titleRecovered": "Achtergrondtaak werkt weer: {queue}",
"bodyFailing": "De achtergrondtaak {queue} is {failures} keer achter elkaar mislukt en staat op de beheerstatuspagina. Laatste fout: {error}",
"bodyRecovered": "De achtergrondtaak {queue} is weer met succes uitgevoerd."
}
},
"i18n": {
@@ -11250,6 +11274,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, dagelijks van 15.30 tot 0.30 uur)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Nederland, dag en nacht, gratis, anoniem, ook via chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (België, dag en nacht, gratis, anoniem)"
}
},
"instrumentDescription": {
+35 -5
View File
@@ -789,7 +789,10 @@
"title": "Zapisz zestaw odzyskiwania",
"body": "Bez kopii kluczy, których używa ten serwer, jego danych nie da się odzyskać po ponownej instalacji. To zajmie kilka minut.",
"action": "Otwórz zestaw"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Witaj.",
"welcomeNewWithName": "Witaj, {name}."
},
"measurements": {
"title": "Pomiary",
@@ -6237,14 +6240,17 @@
"password": "Hasło",
"totp": "Kod z aplikacji uwierzytelniającej",
"passkey": "Klucz dostępu",
"webauthn": "Klucz bezpieczeństwa"
"webauthn": "Klucz bezpieczeństwa",
"oidc": "Dostawca tożsamości"
},
"currentPassword": "Aktualne hasło",
"authenticatorCode": "Kod z aplikacji uwierzytelniającej",
"authenticatorPlaceholder": "6-cyfrowy kod z aplikacji uwierzytelniającej",
"verifyAndAdd": "Zweryfikuj i dodaj",
"factorDescription": "Potwierdź istniejącą metodę logowania, zanim dodasz drugi składnik.",
"verifyAndContinue": "Potwierdź i kontynuuj"
"verifyAndContinue": "Potwierdź i kontynuuj",
"oidcHint": "Zalogujesz się jeszcze raz u swojego dostawcy tożsamości i wrócisz od razu na tę stronę. Potem powtórz to, co chcesz zrobić.",
"continueToProvider": "Przejdź do logowania"
},
"passkeyName": "Nazwa",
"noPasskeys": "Brak zarejestrowanych kluczy dostępu.",
@@ -7517,7 +7523,9 @@
"description": "Ta czynność udostępnia lub eksportuje Twoje dane zdrowotne. Potwierdź, że to Ty, aby kontynuować.",
"descriptionChange": "To zmienia coś, co tylko Ty powinieneś móc zmienić. Potwierdź, że to Ty, aby kontynuować.",
"signInAgain": "Zaloguj się ponownie, a potem spróbuj jeszcze raz. Twoje konto nie ma hasła ani klucza dostępu do potwierdzenia tutaj.",
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie."
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie.",
"oidcDone": "Potwierdzono. Przez następne pięć minut możesz kontynuować bez ponownego pytania.",
"oidcFailed": "Twój dostawca tożsamości nie mógł potwierdzić, że to Ty. Spróbuj ponownie."
},
"passwordTitle": "Hasło",
"tokenNameLabel": "Nazwa tokena",
@@ -8105,7 +8113,16 @@
"undoDeletionBody": "{name} poprosił o usunięcie tego konta; zostanie ono usunięte po upływie okresu karencji. Cofnięcie zachowuje konto i całą jego zawartość bez zmian. {name} może znów się zalogować, ale tokeny API, linki udostępniania i zaufane urządzenia pozostają unieważnione i trzeba je skonfigurować od nowa.",
"undoDeletionBodyGuardian": "Osoba opiekująca się profilem usunęła profil zarządzany {name}; zostanie on usunięty po upływie okresu karencji. Cofnięcie zachowuje profil i całą jego zawartość bez zmian, a osoby opiekujące się nim znów mają do niego dostęp. Jego linki udostępniania i tokeny pozostają unieważnione i trzeba je skonfigurować od nowa.",
"undoDeletionDone": "Cofnięto usunięcie {name}",
"undoDeletionFailed": "Nie udało się cofnąć usunięcia"
"undoDeletionFailed": "Nie udało się cofnąć usunięcia",
"undoDeletionBodyAdmin": "Administrator usunął konto {name}; zostanie ono usunięte po upływie okresu karencji. Cofnięcie zachowuje konto i całą jego zawartość bez zmian. {name} może znów się zalogować, ale tokeny API, linki udostępniania i zaufane urządzenia pozostają unieważnione i trzeba je skonfigurować od nowa.",
"deleteUser": "Usuń konto",
"deleteUserTitle": "Usunąć konto {name}?",
"deleteUserBodyGrace": "{name} zostanie od razu wszędzie wylogowany i nie będzie mógł się już zalogować. Konto i cała jego zawartość zostaną usunięte {date}. Do tego czasu możesz tutaj cofnąć usunięcie.",
"deleteUserBodyNow": "{name} i cała zawartość konta zostaną usunięte od razu. Ten serwer nie ma okresu karencji, więc usunięcia nie da się cofnąć.",
"deleteUserConfirm": "Usuń konto",
"deleteUserScheduled": "Konto {name} jest zablokowane i zostanie usunięte {date}",
"deleteUserDone": "Konto {name} zostało usunięte",
"deleteUserFailed": "Nie udało się usunąć konta"
},
"invites": {
"title": "Zaproszenia",
@@ -9243,6 +9260,13 @@
"titleFailing": "Kopie zapasowe są zagrożone",
"titleReminder": "Kopie zapasowe są nadal zagrożone",
"titleRecovered": "Kopie zapasowe znów działają normalnie"
},
"systemJobAlert": {
"titleFailing": "Zadanie w tle kończy się błędem: {queue}",
"titleReminder": "Zadanie w tle nadal kończy się błędem: {queue}",
"titleRecovered": "Zadanie w tle znów działa: {queue}",
"bodyFailing": "Zadanie w tle {queue} zakończyło się błędem {failures} razy z rzędu i jest widoczne na stronie stanu w panelu administratora. Ostatni błąd: {error}",
"bodyRecovered": "Zadanie w tle {queue} znów wykonało się poprawnie."
}
},
"i18n": {
@@ -11250,6 +11274,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugalia, codziennie od 15:30 do 0:30)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Holandia, całą dobę, bezpłatnie, anonimowo, także przez czat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Belgia, całą dobę, bezpłatnie, anonimowo)"
}
},
"instrumentDescription": {
+35 -5
View File
@@ -789,7 +789,10 @@
"title": "Guarde seu kit de recuperação",
"body": "Sem cópias das chaves que este servidor usa, os dados dele não podem ser recuperados após uma reinstalação. Leva poucos minutos.",
"action": "Abrir o kit"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Boas-vindas.",
"welcomeNewWithName": "Boas-vindas, {name}."
},
"measurements": {
"title": "Medições",
@@ -6237,14 +6240,17 @@
"password": "Senha",
"totp": "Código do autenticador",
"passkey": "Chave de acesso",
"webauthn": "Chave de segurança"
"webauthn": "Chave de segurança",
"oidc": "Provedor de identidade"
},
"currentPassword": "Senha atual",
"authenticatorCode": "Código do autenticador",
"authenticatorPlaceholder": "Código do autenticador de 6 dígitos",
"verifyAndAdd": "Verificar e adicionar",
"factorDescription": "Verifique um método de entrada existente antes de adicionar um segundo fator.",
"verifyAndContinue": "Verificar e continuar"
"verifyAndContinue": "Verificar e continuar",
"oidcHint": "Você entra mais uma vez no seu provedor de identidade e volta direto para esta página. Depois, repita o que ia fazer.",
"continueToProvider": "Continuar para o login"
},
"passkeyName": "Nome",
"noPasskeys": "Nenhuma chave de acesso registrada.",
@@ -7517,7 +7523,9 @@
"description": "Isto compartilha ou exporta os seus dados de saúde. Confirme que é você para continuar.",
"descriptionChange": "Isto altera algo que só você deveria poder alterar. Confirme que é você para continuar.",
"signInAgain": "Entre novamente e tente mais uma vez. Sua conta não tem senha nem chave de acesso para confirmar aqui.",
"failed": "Não deu certo. Confira o que você digitou e tente de novo."
"failed": "Não deu certo. Confira o que você digitou e tente de novo.",
"oidcDone": "Confirmado. Nos próximos cinco minutos você pode continuar sem que isso seja pedido de novo.",
"oidcFailed": "Seu provedor de identidade não conseguiu confirmar que é você. Tente novamente."
},
"passwordTitle": "Senha",
"tokenNameLabel": "Nome do token",
@@ -8105,7 +8113,16 @@
"undoDeletionBody": "{name} pediu para excluir esta conta, e ela é apagada quando o prazo de carência termina. Desfazer mantém a conta e tudo o que há nela como estava. {name} pode entrar de novo, mas os tokens de API, os links de compartilhamento e os dispositivos confiáveis continuam revogados e precisam ser configurados de novo.",
"undoDeletionBodyGuardian": "Um guardião excluiu o perfil gerenciado {name}, e ele é apagado quando o prazo de carência termina. Desfazer mantém o perfil e tudo o que há nele como estava, e os guardiões voltam a ter acesso. Os links de compartilhamento e tokens continuam revogados e precisam ser configurados de novo.",
"undoDeletionDone": "Exclusão de {name} desfeita",
"undoDeletionFailed": "Não foi possível desfazer a exclusão"
"undoDeletionFailed": "Não foi possível desfazer a exclusão",
"undoDeletionBodyAdmin": "Um administrador excluiu a conta {name}, e ela é apagada quando o prazo de carência termina. Desfazer mantém a conta e tudo o que há nela como estava. {name} pode entrar de novo, mas os tokens de API, os links de compartilhamento e os dispositivos confiáveis continuam revogados e precisam ser configurados de novo.",
"deleteUser": "Excluir conta",
"deleteUserTitle": "Excluir a conta {name}?",
"deleteUserBodyGrace": "{name} sai da conta em todos os lugares na hora e não consegue mais entrar. A conta e tudo o que há nela são apagados em {date}. Até lá, você pode desfazer a exclusão aqui.",
"deleteUserBodyNow": "{name} e tudo o que há na conta são apagados na hora. Este servidor não tem prazo de carência, então a exclusão não pode ser desfeita.",
"deleteUserConfirm": "Excluir conta",
"deleteUserScheduled": "A conta {name} está bloqueada e será apagada em {date}",
"deleteUserDone": "A conta {name} foi excluída",
"deleteUserFailed": "Não foi possível excluir a conta"
},
"invites": {
"title": "Convites",
@@ -9243,6 +9260,13 @@
"titleFailing": "Os backups estão em risco",
"titleReminder": "Os backups continuam em risco",
"titleRecovered": "Os backups voltaram ao normal"
},
"systemJobAlert": {
"titleFailing": "Tarefa em segundo plano falhando: {queue}",
"titleReminder": "Tarefa em segundo plano ainda falhando: {queue}",
"titleRecovered": "Tarefa em segundo plano funcionando de novo: {queue}",
"bodyFailing": "A tarefa em segundo plano {queue} falhou {failures} vezes seguidas e aparece na página de status da administração. Último erro: {error}",
"bodyRecovered": "A tarefa em segundo plano {queue} voltou a ser executada com sucesso."
}
},
"i18n": {
@@ -11250,6 +11274,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, todos os dias das 15h30 às 0h30)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Países Baixos, 24 horas, gratuito, anônimo, também por chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Bélgica, 24 horas, gratuito, anônimo)"
}
},
"instrumentDescription": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.46.1",
"version": "1.46.2",
"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",
+5
View File
@@ -310,6 +310,11 @@ export default defineConfig({
// other. It checks the phone width by resizing inside the desktop
// project.
"score-history.spec.ts",
// The header-height sweep moves its account's language through every
// locale; a second project would switch it under the first between
// the switch and the measurement. It sets the 390 px phone width
// itself inside the desktop project.
"header-height-locales.spec.ts",
],
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
@@ -0,0 +1,24 @@
-- System job alert: what the admins were last told about a background job of
-- the instance that keeps failing.
--
-- `system_job_alert_notices` holds, per pg-boss queue, the state the admins
-- were last notified about and when. It is the whole memory of the hourly
-- evaluation (`src/lib/jobs/system-job-alert.ts`): a queue that has failed
-- three runs in a row notifies once, is reminded at most once a day while it
-- keeps failing, and its first successful run after that is announced once.
-- A row exists only for a queue something was announced about.
--
-- One new table, nothing else touched, so the migration takes no lock on an
-- existing table and running it again changes nothing.
--
-- Reversibility: dropping the table loses only the memory of what was
-- announced; the next evaluation announces a still-failing queue once more.
CREATE TABLE IF NOT EXISTS "system_job_alert_notices" (
"queue" TEXT NOT NULL,
"notified_state" TEXT,
"notified_at" TIMESTAMP(3),
CONSTRAINT "system_job_alert_notices_pkey" PRIMARY KEY ("queue"),
CONSTRAINT "system_job_alert_notices_state_check"
CHECK ("notified_state" IS NULL OR "notified_state" IN ('ok', 'failing'))
);
+17
View File
@@ -5958,6 +5958,23 @@ model RecoveryReadinessNotice {
@@map("recovery_readiness_notices")
}
/// Per background queue of the instance, the state the admins were last
/// notified about. The hourly system job evaluation compares a queue's run
/// history against it: three failed runs in a row notify once, a queue still
/// failing is reminded at most once a day, and its first successful run after
/// that is announced once. The update is a compare-and-set on both columns, so
/// two workers never send the same notice. Written and read by
/// `notifySystemJobFailures`.
model SystemJobAlertNotice {
/// The pg-boss queue name (`rollup-full-backfill`, …).
queue String @id
/// `ok` or `failing`; null once a queue's history has aged out.
notifiedState String? @map("notified_state")
notifiedAt DateTime? @map("notified_at")
@@map("system_job_alert_notices")
}
/// The recovery kit an admin confirmed: every key a restore needs, by kind,
/// id and fingerprint, never the key itself. The kit is due again as soon as
/// a key it needs is not in this set (a rotation, a new backup key). Written
+1 -1
View File
@@ -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.46.1";
/* @sw-version-fallback */ "v1.46.2";
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/*`
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 94928,
"total": 94692,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -71,14 +71,14 @@
"lib/managed-profiles/handover.ts": 986,
"lib/mcp/record-search.ts": 1467,
"lib/mcp/rich-reads.ts": 1467,
"lib/measurements/dense-intraday-retention.ts": 1057,
"lib/measurements/dense-intraday-retention.ts": 1016,
"lib/measurements/import-apple-health-export.ts": 1722,
"lib/medications/scheduling/cadence.ts": 976,
"lib/medications/scheduling/recurrence.ts": 1100,
"lib/medications/scheduling/slot-upsert.ts": 912,
"lib/notifications/senders/apns.ts": 1100,
"lib/oura/sync.ts": 900,
"lib/rollups/measurement-rollups.ts": 1496,
"lib/rollups/measurement-rollups.ts": 1302,
"lib/search/search-records.ts": 996,
"lib/sharing/grants.ts": 806,
"lib/support/report.ts": 903,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 346,
"total": 112297,
"total": 112219,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
@@ -9,7 +9,7 @@
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 281,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
@@ -217,7 +217,7 @@
"components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234,
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
"components/settings/score-section.tsx :: ScoreSection": 383,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 410,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390,
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,
@@ -319,9 +319,9 @@
"lib/labs/biomarker-merge.ts :: runMerge": 254,
"lib/measurements/consolidate-daily-mean.ts :: consolidateDailyMean": 278,
"lib/measurements/consolidate-legacy-steps.ts :: consolidateLegacySteps": 263,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention": 537,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource": 447,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay": 373,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention": 539,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource": 448,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay": 374,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay > foldOnce": 212,
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative": 344,
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative > writeDay": 205,
+2 -3
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 524,
"total": 5488,
"count": 523,
"total": 5483,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -337,7 +337,6 @@
"lib/auth/__tests__/mfa-enrollment.test.ts": 8,
"lib/auth/__tests__/native-handoff-flow.test.ts": 10,
"lib/auth/__tests__/oidc-native-handoff.test.ts": 13,
"lib/auth/__tests__/oidc.test.ts": 5,
"lib/auth/__tests__/passkey.test.ts": 7,
"lib/auth/__tests__/profile-update-email-limit.test.ts": 5,
"lib/auth/__tests__/profile-update.test.ts": 4,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2897,
"total": 9236,
"count": 2896,
"total": 9228,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -175,9 +175,9 @@
"app/api/auth/refresh/route.ts": 2,
"app/api/auth/register/__tests__/route.test.ts": 5,
"app/api/auth/register/route.ts": 10,
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
"app/api/biomarkers/[id]/__tests__/route.test.ts": 2,
"app/api/biomarkers/[id]/route.ts": 6,
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
"app/api/biomarkers/route.ts": 4,
"app/api/c/[token]/unlock/__tests__/route.test.ts": 1,
"app/api/c/[token]/unlock/route.ts": 1,
@@ -187,12 +187,12 @@
"app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts": 1,
"app/api/coach/about-me/questions/route.ts": 3,
"app/api/coach/about-me/route.ts": 4,
"app/api/coach/plans/[id]/route.ts": 2,
"app/api/coach/plans/__tests__/route.test.ts": 1,
"app/api/coach/plans/[id]/route.ts": 2,
"app/api/coach/plans/route.ts": 1,
"app/api/coach/reminder-suggestions/route.ts": 1,
"app/api/coach/reminders/[id]/route.ts": 1,
"app/api/coach/reminders/__tests__/route.test.ts": 1,
"app/api/coach/reminders/[id]/route.ts": 1,
"app/api/coach/reminders/route.ts": 1,
"app/api/coach/suggested-actions/__tests__/route.test.ts": 1,
"app/api/coach/suggested-actions/route.ts": 1,
@@ -200,9 +200,9 @@
"app/api/consent/ai/latest/__tests__/route.test.ts": 3,
"app/api/consent/ai/latest/route.ts": 3,
"app/api/consent/ai/route.ts": 3,
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
"app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 1,
"app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 1,
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
"app/api/custom-metrics/[id]/entries/route.ts": 4,
"app/api/custom-metrics/[id]/route.ts": 1,
"app/api/custom-metrics/route.ts": 1,
@@ -212,8 +212,8 @@
"app/api/cycle/day-logs/[id]/route.ts": 4,
"app/api/cycle/day-logs/route.ts": 1,
"app/api/cycle/period/route.ts": 4,
"app/api/cycle/symptoms/custom/[key]/route.ts": 1,
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1,
"app/api/cycle/symptoms/custom/[key]/route.ts": 1,
"app/api/cycle/symptoms/custom/route.ts": 3,
"app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts": 1,
"app/api/dashboard/chart-overlay-prefs/route.ts": 2,
@@ -223,10 +223,11 @@
"app/api/dashboard/summary/route.ts": 5,
"app/api/dashboard/widgets/__tests__/route.test.ts": 4,
"app/api/dashboard/widgets/route.ts": 14,
"app/api/devices/__tests__/route.test.ts": 1,
"app/api/devices/[id]/__tests__/route.test.ts": 1,
"app/api/devices/[id]/route.ts": 1,
"app/api/devices/__tests__/route.test.ts": 1,
"app/api/devices/route.ts": 4,
"app/api/documents/inbound/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 2,
"app/api/documents/inbound/[id]/chat/route.ts": 1,
@@ -241,7 +242,6 @@
"app/api/documents/inbound/[id]/suggest/route.ts": 2,
"app/api/documents/inbound/[id]/summary/route.ts": 2,
"app/api/documents/inbound/[id]/thumbnail/route.ts": 1,
"app/api/documents/inbound/__tests__/route.test.ts": 1,
"app/api/documents/inbound/reindex/route.ts": 1,
"app/api/documents/inbound/route.ts": 2,
"app/api/documents/inbound/usage/route.ts": 1,
@@ -260,10 +260,10 @@
"app/api/export/mood/route.ts": 2,
"app/api/family-history/[id]/route.ts": 2,
"app/api/family-history/route.ts": 1,
"app/api/fhir/Patient/$everything/route.ts": 1,
"app/api/fhir/__tests__/observation-series.test.ts": 1,
"app/api/fhir/__tests__/route.test.ts": 1,
"app/api/fhir/metadata/route.ts": 1,
"app/api/fhir/Patient/$everything/route.ts": 1,
"app/api/fitbit/callback/route.ts": 1,
"app/api/fitbit/connect/route.ts": 1,
"app/api/fitbit/credentials/route.ts": 1,
@@ -292,11 +292,6 @@
"app/api/insights/breathing-screening/route.ts": 2,
"app/api/insights/cards/__tests__/route.test.ts": 2,
"app/api/insights/cards/route.ts": 9,
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/attachments/route.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/route.ts": 1,
"app/api/insights/chat/__tests__/dialog-harness.ts": 1,
"app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1,
"app/api/insights/chat/__tests__/route-activity.test.ts": 1,
@@ -308,14 +303,19 @@
"app/api/insights/chat/__tests__/route-snapshot-once.test.ts": 2,
"app/api/insights/chat/__tests__/route-tool-mode.test.ts": 4,
"app/api/insights/chat/__tests__/route-workout-scope.test.ts": 1,
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
"app/api/insights/chat/[id]/attachments/route.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/route.ts": 1,
"app/api/insights/chat/fenced/__tests__/route.test.ts": 1,
"app/api/insights/chat/fenced/route.ts": 1,
"app/api/insights/chat/messages/[id]/feedback/route.ts": 3,
"app/api/insights/chat/route.ts": 9,
"app/api/insights/coach-read/route.ts": 2,
"app/api/insights/coach/facts/[id]/route.ts": 2,
"app/api/insights/coach/facts/__tests__/route-memory.test.ts": 1,
"app/api/insights/coach/facts/__tests__/route.test.ts": 1,
"app/api/insights/coach/facts/[id]/route.ts": 2,
"app/api/insights/coach/facts/route.ts": 1,
"app/api/insights/coach/nudge-status/route.ts": 1,
"app/api/insights/coach/seen/route.ts": 1,
@@ -326,9 +326,9 @@
"app/api/insights/derived/batch/__tests__/route.test.ts": 3,
"app/api/insights/derived/batch/route.ts": 6,
"app/api/insights/derived/route.ts": 5,
"app/api/insights/ecg/__tests__/route.test.ts": 1,
"app/api/insights/ecg/[id]/__tests__/route.test.ts": 1,
"app/api/insights/ecg/[id]/route.ts": 2,
"app/api/insights/ecg/__tests__/route.test.ts": 1,
"app/api/insights/ecg/route.ts": 1,
"app/api/insights/feedback/route.ts": 2,
"app/api/insights/generate/__tests__/route.test.ts": 12,
@@ -367,8 +367,8 @@
"app/api/internal/deploy-webhook/route.ts": 1,
"app/api/internal/web-vitals/__tests__/route.test.ts": 1,
"app/api/internal/web-vitals/route.ts": 1,
"app/api/labs/[id]/route.ts": 7,
"app/api/labs/__tests__/error-contract.test.ts": 1,
"app/api/labs/[id]/route.ts": 7,
"app/api/labs/ocr/capability/route.ts": 1,
"app/api/labs/ocr/commit/__tests__/route.test.ts": 2,
"app/api/labs/ocr/commit/route.ts": 4,
@@ -388,13 +388,13 @@
"app/api/measurement-reminders/[id]/skip/route.ts": 1,
"app/api/measurement-reminders/[id]/snooze/route.ts": 1,
"app/api/measurement-reminders/route.ts": 3,
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
"app/api/measurements/[id]/route.ts": 16,
"app/api/measurements/__tests__/arrival-wiring.test.ts": 1,
"app/api/measurements/__tests__/group-by-day.test.ts": 8,
"app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 1,
"app/api/measurements/__tests__/range-aggregation-route.test.ts": 3,
"app/api/measurements/__tests__/zod-multi-issue.test.ts": 4,
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
"app/api/measurements/[id]/route.ts": 16,
"app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 1,
"app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 2,
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 1,
@@ -411,6 +411,7 @@
"app/api/measurements/series-batch/route.ts": 1,
"app/api/measurements/series/__tests__/route.test.ts": 5,
"app/api/measurements/series/route.ts": 6,
"app/api/medications/__tests__/route.test.ts": 10,
"app/api/medications/[id]/__tests__/route.test.ts": 8,
"app/api/medications/[id]/api-endpoint/route.ts": 2,
"app/api/medications/[id]/cadence/route.ts": 10,
@@ -422,10 +423,10 @@
"app/api/medications/[id]/dose-history/route.ts": 14,
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 3,
"app/api/medications/[id]/glp1/route.ts": 8,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
"app/api/medications/[id]/intake/[eventId]/route.ts": 4,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 11,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
"app/api/medications/[id]/intake/[eventId]/route.ts": 4,
"app/api/medications/[id]/intake/bulk-delete/route.ts": 7,
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2,
"app/api/medications/[id]/intake/import/route.ts": 6,
@@ -433,12 +434,11 @@
"app/api/medications/[id]/inventory/[itemId]/route.ts": 8,
"app/api/medications/[id]/inventory/route.ts": 4,
"app/api/medications/[id]/phase-config/route.ts": 4,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
"app/api/medications/[id]/schedule-revisions/route.ts": 3,
"app/api/medications/[id]/side-effects/[logId]/route.ts": 2,
"app/api/medications/[id]/side-effects/route.ts": 5,
"app/api/medications/__tests__/route.test.ts": 10,
"app/api/medications/categories/[key]/route.ts": 1,
"app/api/medications/categories/route.ts": 1,
"app/api/medications/compliance/__tests__/route.test.ts": 2,
@@ -455,9 +455,9 @@
"app/api/medications/route.ts": 2,
"app/api/mental-health/assessments/__tests__/route.test.ts": 2,
"app/api/mental-health/assessments/route.ts": 6,
"app/api/mood-entries/__tests__/route.test.ts": 7,
"app/api/mood-entries/[id]/__tests__/route.test.ts": 4,
"app/api/mood-entries/[id]/route.ts": 22,
"app/api/mood-entries/__tests__/route.test.ts": 7,
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 1,
"app/api/mood-entries/bulk-delete/route.ts": 4,
"app/api/mood-entries/bulk/__tests__/route.test.ts": 5,
@@ -547,12 +547,12 @@
"app/api/withings/connect/__tests__/route.test.ts": 1,
"app/api/withings/connect/route.ts": 3,
"app/api/withings/status/route.ts": 1,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
"app/api/withings/webhook/__tests__/route.test.ts": 1,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
"app/api/withings/webhook/route.ts": 2,
"app/api/workouts/[id]/route.ts": 2,
"app/api/workouts/__tests__/batch-create.test.ts": 3,
"app/api/workouts/__tests__/canonical-dedup.test.ts": 3,
"app/api/workouts/[id]/route.ts": 2,
"app/api/workouts/batch/route.ts": 3,
"app/api/workouts/route.ts": 2,
"app/auth/claim/page.tsx": 1,
@@ -654,20 +654,20 @@
"app/loading.tsx": 2,
"app/mcp/__tests__/route.test.ts": 5,
"app/measurements/page.tsx": 5,
"app/medications/[id]/history/page.tsx": 1,
"app/medications/[id]/page.tsx": 2,
"app/medications/__tests__/category-filter.test.tsx": 2,
"app/medications/__tests__/detail-page-history-surface.test.ts": 1,
"app/medications/__tests__/list-view-toggle.test.tsx": 5,
"app/medications/__tests__/page-prefetch.test.tsx": 1,
"app/medications/__tests__/take-all-due-affordance.test.tsx": 1,
"app/medications/[id]/history/page.tsx": 1,
"app/medications/[id]/page.tsx": 2,
"app/medications/new/page.tsx": 1,
"app/medications/page-client.tsx": 50,
"app/mental-wellbeing/page.tsx": 1,
"app/mood/page-client.tsx": 2,
"app/not-found.tsx": 2,
"app/onboarding/[step]/page.tsx": 1,
"app/onboarding/__tests__/page.test.tsx": 1,
"app/onboarding/[step]/page.tsx": 1,
"app/onboarding/handover/page.tsx": 1,
"app/onboarding/page.tsx": 2,
"app/privacy/__tests__/page.test.tsx": 1,
@@ -2067,7 +2067,6 @@
"lib/google-health/sync-metrics.ts": 1,
"lib/i18n/__tests__/relative-time-updated.test.ts": 2,
"lib/i18n/config.ts": 1,
"lib/i18n/context.tsx": 1,
"lib/i18n/relative-time.ts": 4,
"lib/i18n/shared-resolve.ts": 1,
"lib/i18n/source-labels.ts": 1,
@@ -2459,7 +2458,7 @@
"lib/measurements/cumulative-day-sum.ts": 6,
"lib/measurements/daily-series-read.ts": 2,
"lib/measurements/dense-intraday-hourly-rebuild.ts": 1,
"lib/measurements/dense-intraday-retention.ts": 4,
"lib/measurements/dense-intraday-retention.ts": 3,
"lib/measurements/display-transform.ts": 6,
"lib/measurements/drain-per-sample-cumulative.ts": 8,
"lib/measurements/import-apple-health-export.ts": 5,
@@ -2748,7 +2747,7 @@
"lib/rollups/measurement-coverage.ts": 1,
"lib/rollups/measurement-read-wmy.ts": 4,
"lib/rollups/measurement-read.ts": 10,
"lib/rollups/measurement-rollups.ts": 18,
"lib/rollups/measurement-rollups.ts": 12,
"lib/rollups/mood-rollups.ts": 4,
"lib/rollups/tiered-context.ts": 6,
"lib/score-config/rows.ts": 1,
@@ -679,6 +679,17 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/admin/delete-user-control.tsx",
destroys:
"another user's whole account, locked at once and erased when the deletion grace window ends (or at once when the window is 0)",
triggers: ["components/admin/user-management-section.tsx"],
// The undo route exists and the row offers it until the purge date
// (`pending-deletion-controls.tsx`); with the window at 0 there is
// nothing to undo, and the dialog says so.
recovery: "restorable",
confirm: ["ConfirmButton"],
},
// ── Integrations ────────────────────────────────────────────────────────
// Every connection renders the shared integration card, whose three-dot
@@ -163,6 +163,11 @@ const ALLOWED: Readonly<Record<string, { count: number; reason: string }>> = {
count: 1,
reason: "No /proc/diskstats outside Linux; see captureHostMetric.",
},
"lib/jobs/system-job-alert.ts::readTerminalRuns": {
count: 1,
reason:
"No pgboss schema on a web-only deployment; null means no queue to ask, and the evaluation then judges nothing.",
},
"lib/jobs/job-failures.ts::readActiveJobsStartedBefore": {
count: 1,
reason:
@@ -65,6 +65,8 @@ const NOT_MODEL_INPUT: Record<string, string> = {
"links stored rows to their biomarker; no model is involved",
"src/lib/jobs/lab-biomarker-backfill.ts#enqueueBootTimeLabBiomarkerBackfill":
"finds the accounts whose rows need linking; no model is involved",
"src/lib/jobs/lab-document-date-repair.ts#candidateQuery":
"finds document lab values filed at their confirmation moment to re-date them; no model is involved",
"src/lib/jobs/soft-delete-retention.ts#SOFT_DELETE_RETENTION":
"purges rows past the trash window; no model is involved",
"src/lib/insights/correlation-channel-series.ts#fetchLabDraws":
+15 -3
View File
@@ -145,15 +145,27 @@ describe("managed-profile contract", () => {
path.join(root, "src/lib/sharing/grants.ts"),
"utf8",
);
const accountDeletion = await readFile(
path.join(root, "src/app/api/settings/account/route.ts"),
// Both account deletions (the account's own and an admin's) take the one
// request path, and that path decides the Guardian invariant.
const deletionRequest = await readFile(
path.join(root, "src/lib/account-deletion/request.ts"),
"utf8",
);
const deletionRoutes = await Promise.all(
[
"src/app/api/settings/account/route.ts",
"src/app/api/admin/users/[id]/route.ts",
].map((rel) => readFile(path.join(root, rel), "utf8")),
);
expect(lifecycle).toContain("pg_advisory_xact_lock");
expect(lifecycle).toContain("clearManagedProfileMarker");
expect(lifecycle).toContain("deleteManagedProfile");
expect(grants).toContain("reduceManagedProfileGuardian");
expect(accountDeletion).toContain("deleteGuardianAccountWithLifecycle");
expect(deletionRequest).toContain("deleteGuardianAccountWithLifecycle(");
for (const route of deletionRoutes) {
expect(route).toContain("requestAccountDeletion(");
expect(route).not.toContain("lockAccountForDeletion(");
}
});
});
@@ -110,6 +110,8 @@ const WRITE_SITES: Record<string, string> = {
"value-preserving hourly fold; refolds the touched spans",
"lib/measurements/dense-intraday-retention.ts":
"value-preserving retention fold; refolds the touched days",
"lib/measurements/dense-fold-removal.ts":
"removes the samples the retention fold has just taken into its hourly means, inside the fold's transaction; the fold refolds the touched days",
"lib/jobs/sleep-timeline-backfill.ts":
"one-shot re-stamp of stored sleep rows onto their timeline; refolds the touched days",
"lib/jobs/step-consolidation-repair.ts":
@@ -295,7 +295,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["DELETE", "PUT"] },
"/api/admin/users/{id}/force-logout": {
kind: "adminConsole",
methods: ["POST"],
@@ -308,6 +308,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
kind: "adminConsole",
methods: ["POST"],
},
// #1296 — the identity-provider re-proof start. A top-level navigation from
// the web's re-proof dialog that ends at the provider's sign-in page; the
// native client never re-proves this way (Bearer has its own step-up).
"/api/auth/oidc/reproof": { kind: "browserHandoff", methods: ["GET"] },
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
// `/api/nightscout/connect` sat here as a `browserHandoff` and is published
@@ -111,6 +111,7 @@ const COOKIE_PROOF_ROUTES = [
"app/api/admin/backups/upload/route.ts",
"app/api/admin/data/route.ts",
"app/api/admin/users/[id]/reset-password/route.ts",
"app/api/admin/users/[id]/route.ts",
"app/api/auth/reproof/route.ts",
"app/api/tokens/documents/route.ts",
"app/api/tokens/measurements/route.ts",
+4 -1
View File
@@ -102,8 +102,11 @@ describe("S1 — the direct session-resolution set is frozen", () => {
// replayed callback redirects instead of minting a second identity, and
// (v1.43, #959) so a single-sign-on claim is refused over a live session,
// as the claim routes refuse it.
// And the identity-provider re-proof (#1296), which stamps only
// the session the start bound into the sealed state, and only while the
// browser still carries it.
"app/api/auth/oidc/callback/route.ts":
"duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route",
"duplicate-callback probe, the claim's live-session refusal and the re-proof's session binding on an otherwise anonymous route",
// Public liveness probe. The session read only decides whether the body
// carries operational detail; the status code is computed without it.
"app/api/health/route.ts": "admin-only detail on a public probe",
@@ -112,6 +112,7 @@ function discoverGatedRoutes(): string[] {
*/
const GATED_ROUTES: Record<string, string> = {
"DELETE /api/admin/data": "web",
"DELETE /api/admin/users/[id]": "web",
"DELETE /api/auth/me/mfa/webauthn/[id]": "web",
"DELETE /api/auth/passkeys/[id]": "web",
"DELETE /api/managed-profiles/[id]": "web",
+14 -7
View File
@@ -15,9 +15,10 @@
* { userId?: string }
*
* - `userId` — single user mode. Synchronously calls
* `recomputeUserRollups(userId)` and returns the row count when
* the fold completes. The call is awaited inside the request, so
* the operator sees the duration in the response.
* `foldUserRollupHistory(userId)` (the full fold, a calendar year per
* statement, each statement under the boot job's budget) and returns the
* row count when the fold completes. The call is awaited inside the
* request, so the operator sees the duration in the response.
* - `userId` omitted — calls `enqueueBootTimeRollupBackfill()` to
* re-run the worker-boot discovery (`{ enqueued, skipped }` shape).
*
@@ -37,8 +38,9 @@ import {
import { annotate } from "@/lib/logging/context";
import {
enqueueBootTimeRollupBackfill,
recomputeUserRollups,
} from "@/lib/rollups/measurement-rollups";
foldUserRollupHistory,
ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS,
} from "@/lib/rollups/full-backfill";
interface RecomputeBody {
userId?: string;
@@ -68,9 +70,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
details: { targetUserId: userId, mode: "single" },
});
let result: Awaited<ReturnType<typeof recomputeUserRollups>>;
let result: Awaited<ReturnType<typeof foldUserRollupHistory>>;
try {
result = await recomputeUserRollups(userId);
// The per-statement budget the boot job folds with: this mode is for
// the accounts the boot job could not finish, and under the database's
// ordinary limit their yearly statements time out here too.
result = await foldUserRollupHistory(userId, {
statementTimeoutMs: ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return apiError(message, 500);
+140 -1
View File
@@ -3,13 +3,21 @@ import {
assertAnotherAdminStays,
LastAdminError,
} from "@/lib/auth/admin-roster";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireAdmin,
} from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { requestAccountDeletion } from "@/lib/account-deletion/request";
import { lockPendingAccount } from "@/lib/account-deletion/lifecycle";
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
import { adminDeleteUserSchema } from "@/lib/validations/admin";
const updateUserSchema = z
.object({
@@ -122,3 +130,134 @@ export const PUT = apiHandler(
});
},
);
/** A refusal decided under the account's row lock, inside the transaction. */
class DeletionRefusal extends Error {
constructor(readonly kind: "not_found" | "managed_profile" | "pending") {
super(`admin deletion refused: ${kind}`);
this.name = "DeletionRefusal";
}
}
function deletionRefusalResponse(kind: DeletionRefusal["kind"]): Response {
if (kind === "not_found") return apiError("User not found", 404);
if (kind === "managed_profile") {
return apiError("A managed profile is deleted by its Guardians", 409, {
errorCode: "admin.users.delete.managed_profile",
});
}
return apiError("This account is already pending deletion", 409, {
errorCode: "account.deletion.already_pending",
});
}
/**
* DELETE /api/admin/users/{id} — an admin deletes another account.
*
* The same request the account's own deletion makes
* (`requestAccountDeletion`): with a grace window the account is locked at
* once (every credential revoked, sign-in refused) and erased by the purge job
* when the window ends, and `POST /api/admin/users/{id}/undo-deletion` gives it
* back until then; with `ACCOUNT_DELETION_GRACE_DAYS=0` it is erased here.
*
* Cookie-only (`requireAdmin`) behind a fresh proof (`assertRecentCookieProof`,
* the gate in front of the other admin acts on someone's data), with the typed
* confirmation the self-deletion asks for. Refused for the caller's own
* account (that is the account settings' deletion, with its own step-up), for
* the last admin who can sign in, for a managed profile (its Guardians delete
* it), for an account already pending deletion, and for the last Guardian of
* a managed profile. Audit-logged for the admin and on the account itself.
*/
export const DELETE = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user: admin, session } = await requireAdmin();
await assertRecentCookieProof(admin, session.id);
const { id } = await params;
annotate({
action: {
name: "admin.users.delete",
entity_type: "user",
entity_id: id,
},
});
const parsed = await parseJsonBody(request, adminDeleteUserSchema, {
maxBytes: 4 * 1024,
});
if (!parsed.ok) return parsed.error;
if (id === admin.id) {
return apiError(
"Delete your own account from your account settings",
400,
{ errorCode: "admin.users.delete.self" },
);
}
const target = await prisma.user.findUnique({
where: { id },
select: { role: true, username: true },
});
if (!target) return apiError("User not found", 404);
let purgeAt: Date | null;
try {
({ purgeAt } = await requestAccountDeletion({
userId: id,
targetIsAdmin: target.role === "ADMIN",
requestedById: admin.id,
inTransaction: async (tx, scheduledPurgeAt) => {
const state = await lockPendingAccount(tx, id);
if (!state) throw new DeletionRefusal("not_found");
if (state.managedProfile)
throw new DeletionRefusal("managed_profile");
if (state.pending) throw new DeletionRefusal("pending");
const details = {
targetUserId: id,
username: target.username,
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
};
await auditLog("admin.user.delete", {
userId: admin.id,
ipAddress: getClientIp(request),
details,
client: tx,
});
// On the account itself too, so its own activity shows who asked.
// An immediate erasure takes this row with the account's history.
await auditLog("user.account.delete", {
userId: id,
actorUserId: admin.id,
details: { ...details, requestedBy: "admin" },
client: tx,
});
},
}));
} catch (error) {
if (error instanceof DeletionRefusal) {
return deletionRefusalResponse(error.kind);
}
if (error instanceof LastAdminError) {
return apiError("The last admin account cannot be deleted", 400);
}
if (error instanceof LastManagedGuardianError) {
return apiError(
"This account is the last Guardian of a managed profile. Add another Guardian or delete the profile first",
409,
{ errorCode: "managed_profile.guardian.required" },
);
}
throw error;
}
annotate({ meta: { deletion_scheduled: purgeAt !== null } });
return apiSuccess({
deleted: true,
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
});
},
);
@@ -100,6 +100,7 @@ describe("GET /api/admin/users", () => {
// BigInt in Prisma, plain number on the wire.
documentQuotaBytes: 2_147_483_648,
passkeyCount: 2,
managedProfile: false,
// Not pending deletion.
deletion: null,
});
@@ -128,6 +129,7 @@ describe("GET /api/admin/users", () => {
deletionRequestedAt: true,
deletionPurgeAt: true,
deletionRequestedById: true,
managedProfileAt: true,
_count: { select: { passkeys: true } },
});
expect(args.select).not.toHaveProperty("passwordHash");
+9 -3
View File
@@ -21,6 +21,7 @@ export const GET = apiHandler(async () => {
deletionRequestedAt: true,
deletionPurgeAt: true,
deletionRequestedById: true,
managedProfileAt: true,
_count: { select: { passkeys: true } },
},
orderBy: { createdAt: "asc" },
@@ -42,9 +43,12 @@ export const GET = apiHandler(async () => {
documentQuotaBytes:
u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes),
passkeyCount: u._count.passkeys,
// A record somebody else runs. Its Guardians delete it, so the list
// offers no admin deletion on it.
managedProfile: u.managedProfileAt != null,
// An account pending deletion: when it was asked for, when the
// purge job erases it, and whether the account asked itself or a
// Guardian deleted the managed profile. Null on every other account.
// purge job erases it, and who asked: the account itself, a Guardian
// (a managed profile), or an admin. Null on every other account.
// The list offers "Undo deletion" on these rows until `purgeAt`.
deletion: u.deletionRequestedAt
? {
@@ -53,7 +57,9 @@ export const GET = apiHandler(async () => {
requestedBy:
u.deletionRequestedById === u.id
? ("self" as const)
: ("guardian" as const),
: u.managedProfileAt != null
? ("guardian" as const)
: ("admin" as const),
}
: null,
})),
+13 -2
View File
@@ -22,19 +22,21 @@ 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";
import { recentProofMethods } from "@/lib/auth/second-factor";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user, authMethod } = await requireAuth();
const [recoveryCodesRemaining, webauthn] = await Promise.all([
const [recoveryCodesRemaining, webauthn, proofMethods] = 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" },
}),
recentProofMethods(user),
]);
annotate({ action: { name: "auth.mfa.status" } });
@@ -46,5 +48,14 @@ export const GET = apiHandler(async () => {
// 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),
// The ways this account can confirm it is you, the list the recent-proof
// refusal names. The passkey card offers exactly these before adding one,
// so an account without a password is not handed a password field. The
// provider round trip stamps a browser session and nothing else, so a
// token is never offered it.
proofMethods:
authMethod === "cookie"
? proofMethods
: proofMethods.filter((method) => method !== "oidc"),
});
});
+19 -12
View File
@@ -93,6 +93,21 @@ const SERVER_FEATURES = {
lifeCircumstances: true,
} as const;
/**
* v1.7.0 — patient-identity fields for the health-record export. The KVNR is
* stored encrypted; decrypt fail-soft so a key-rotation gap on one row never
* 500s the whole profile fetch (the field just reads null and the user
* re-enters it).
*/
function readInsuranceNumber(encrypted: string | null | undefined) {
if (!encrypted) return null;
try {
return decrypt(encrypted);
} catch {
return null;
}
}
export const GET = apiHandler(async () => {
const auth = await requireActorAuth();
const { user } = auth;
@@ -289,22 +304,14 @@ export const GET = apiHandler(async () => {
sectionsOpen(sections, "cycle") &&
isCycleEnabled(record?.gender ?? null, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
// one row never 500s the whole profile fetch (the field just reads
// null and the user re-enters it).
let insuranceNumber: string | null = null;
if (identity?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
const insuranceNumber = readInsuranceNumber(
identity?.insuranceNumberEncrypted,
);
return apiSuccess({
id: user.id,
username: user.username,
createdAt: user.createdAt,
email: user.email,
role: user.role ?? "USER",
heightCm: user.heightCm,
+116 -33
View File
@@ -47,6 +47,11 @@ import {
} from "@/lib/managed-profiles/claim-sso";
import { pendingDeletionOf } from "@/lib/account-deletion/pending";
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
import {
isReproofSession,
recordOidcReproof,
reproofReturnUrl,
} from "@/lib/auth/oidc-reproof";
const LOGIN_ERROR_URL = "/auth/login";
@@ -103,6 +108,20 @@ function deletionPendingRedirect(purgeAt: Date | null): NextResponse {
return response;
}
/**
* #1296 — a re-proof returns to the page it left, never to the sign-in page:
* the person is still signed in, and the session they came from is the one
* thing that leg may touch. `reason` null is the success.
*/
function reproofRedirect(next: string, reason: string | null): NextResponse {
if (reason) annotate({ meta: { reason } });
const response = NextResponse.redirect(
reproofReturnUrl(next, reason ? "failed" : "done"),
);
deleteStateCookie(response);
return response;
}
/**
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
@@ -139,6 +158,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
const stored = readStoredOidcState(req.cookies.get(OIDC_STATE_COOKIE)?.value);
const isNative = stored?.native === true;
const isClaim = stored?.intent === "claim";
// #1296 — a re-proof returns to the page it left (`reproofRedirect`).
const isReproof = stored?.intent === "reproof";
const reproofBack = (reason: string | null) =>
reproofRedirect(stored?.next ?? "/", reason);
// Native-aware error redirect: the custom scheme for a decrypted native flow,
// the web login page otherwise. Deletes the single-use state cookie either
@@ -158,6 +181,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
};
const failRedirect = (reason: string): NextResponse => {
if (isReproof) return reproofBack(reason);
if (isClaim) return claimRedirect(reason);
if (isNative) {
const response = NextResponse.redirect(
@@ -199,42 +223,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
// and there is nothing safe to hand the app (spec §1 web-fallback).
if (!code || !state) {
if (isReproof) return reproofBack("oidc_state");
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (!stored) {
// Defence-in-depth idempotency for a duplicated callback. The single-use
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
// first callback the moment it succeeds. A second callback for the same
// sign-in (a client/proxy retry, a double navigation, or the
// service-worker double-request this release also fixes) therefore arrives
// with the state cookie already gone — `stored` is null — and, because the
// first callback set the session, it carries a live session cookie.
// Redeeming the now-consumed authorization code again fails at the IdP and
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
// when a VALIDATED session is present, treat this as a duplicate of a
// completed login and send the user into the app instead of erroring.
//
// Fail-closed is preserved for the real attack case: the code is NEVER
// exchanged on this branch, and the session is resolved against the
// database + its expiry (not merely "a cookie is present"). A forged,
// replayed, or expired state from a caller with no valid session gets the
// unchanged `oidc_state` error; a caller who already holds their own valid
// session gains nothing here but a redirect to a page they could already
// reach — no code redemption, no session mint, no identity change.
const existingSession = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (existingSession) {
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
const response = NextResponse.redirect(oidcAppUrl("/"));
// The state cookie is already consumed; delete defensively so a stray
// blob can never outlive this response.
deleteStateCookie(response);
return response;
}
return errorRedirect("oidc_state");
}
if (!stored) return stateMissing(req);
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
if (
@@ -242,9 +235,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
) {
annotate({ meta: { reason: "csrf" } });
if (isReproof) return reproofBack("oidc_state");
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (isReproof) return finishReproof(req, stored, config, ip, reproofBack);
// v1.43 (#959) — a claim is never finished over a live session (the claim
// routes' rule: a Guardian finishing their own link would otherwise be
// signed out of their account and into the person's), and is admitted only
@@ -408,3 +404,90 @@ export const GET = apiHandler(async (req: NextRequest) => {
return failRedirect("oidc_failed");
}
});
/**
* #1296 — the re-proof leg. The browser must still carry the session the
* start bound into the state, checked before the code is redeemed; then the
* provider's answer must name this account's linked identity and an
* authentication inside the gate's window. Only then is `reproofAt` stamped.
* No session is created, and every refusal lands back on the page it left.
*/
async function finishReproof(
req: NextRequest,
stored: NonNullable<ReturnType<typeof readStoredOidcState>>,
config: NonNullable<ReturnType<typeof getOidcConfig>>,
ip: string,
back: (reason: string | null) => NextResponse,
): Promise<NextResponse> {
const live = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (!isReproofSession(stored, live)) return back("session_mismatch");
try {
const metadata = await discoverOidcMetadata(config);
const tokens = await exchangeCodeForTokens({
metadata,
config,
code: req.nextUrl.searchParams.get("code") as string,
codeVerifier: stored.codeVerifier,
redirectUri: getOidcRedirectUri(),
});
if (!tokens.id_token) return back("oidc_failed");
const identity = await verifyIdToken({
metadata,
config,
idToken: tokens.id_token,
nonce: stored.nonce,
});
const outcome = await recordOidcReproof({
userId: stored.reproofUserId as string,
sessionId: stored.reproofSessionId as string,
issuer: metadata.issuer,
identity,
ipAddress: ip,
});
return back(outcome.ok ? null : outcome.reason);
} catch (err) {
getEvent()?.setError(err);
return back("exchange_or_verify_failed");
}
}
/**
* A callback that arrives without its state blob: the duplicate of a sign-in
* that already completed, or a forged, replayed or expired one.
*/
async function stateMissing(req: NextRequest): Promise<NextResponse> {
// Defence-in-depth idempotency for a duplicated callback. The single-use
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
// first callback the moment it succeeds. A second callback for the same
// sign-in (a client/proxy retry, a double navigation, or the
// service-worker double-request this release also fixes) therefore arrives
// with the state cookie already gone — `stored` is null — and, because the
// first callback set the session, it carries a live session cookie.
// Redeeming the now-consumed authorization code again fails at the IdP and
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
// when a VALIDATED session is present, treat this as a duplicate of a
// completed login and send the user into the app instead of erroring.
//
// Fail-closed is preserved for the real attack case: the code is NEVER
// exchanged on this branch, and the session is resolved against the
// database + its expiry (not merely "a cookie is present"). A forged,
// replayed, or expired state from a caller with no valid session gets the
// unchanged `oidc_state` error; a caller who already holds their own valid
// session gains nothing here but a redirect to a page they could already
// reach — no code redemption, no session mint, no identity change.
const existingSession = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (existingSession) {
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
const response = NextResponse.redirect(oidcAppUrl("/"));
// The state cookie is already consumed; delete defensively so a stray
// blob can never outlive this response.
deleteStateCookie(response);
return response;
}
return errorRedirect("oidc_state");
}
+118
View File
@@ -0,0 +1,118 @@
import { randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { getClientIp } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
import { recentProofMethods } from "@/lib/auth/second-factor";
import { throttleReproof } from "@/lib/auth/existing-factor-proof";
import {
buildAuthorizationUrl,
discoverOidcMetadata,
getOidcConfig,
getOidcRedirectUri,
sanitizeOidcNextPath,
} from "@/lib/auth/oidc";
import {
OIDC_STATE_COOKIE,
OIDC_STATE_COOKIE_PATH,
OIDC_STATE_TTL_MS,
} from "@/lib/auth/oidc-cookie";
import { OIDC_REPROOF_STAGE, reproofReturnUrl } from "@/lib/auth/oidc-reproof";
export const dynamic = "force-dynamic";
/**
* Start confirming it is you at the identity provider (#1296).
*
* A top-level navigation from the re-proof dialog, cookie session only: a
* Bearer token never re-proves this way, because a stamp on a session row
* means nothing to a token (`requireCookieAuth` reads the session cookie and
* nothing else). Offered only where `recentProofMethods` lists `oidc`, which
* is an account without a second factor whose identity is linked while a
* provider is configured; anything else goes straight back with
* `reproof=failed`.
*
* The round trip asks for a fresh authentication (`prompt=login`,
* `max_age=0`) with its own state, nonce and PKCE verifier, sealed into the
* same single-use cookie the sign-in uses, together with the account and the
* session it confirms. The callback stamps that session's `reproofAt` and
* returns to `next` (a same-origin path) with `reproof=done`. It never mints
* a session.
*
* Draws on the account's shared re-proof budget, like every other proof.
*/
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.reproof.start" } });
const { user, session } = await requireCookieAuth();
const next = sanitizeOidcNextPath(
req.nextUrl.searchParams.get("next"),
req.url,
);
const back = (reason: string): NextResponse => {
annotate({ meta: { reason } });
return NextResponse.redirect(reproofReturnUrl(next, "failed"));
};
const config = getOidcConfig();
if (!config) return back("oidc_disabled");
const methods = await recentProofMethods(user);
if (!methods.includes("oidc")) return back("not_offered");
const limited = await throttleReproof(
user.id,
getClientIp(req),
OIDC_REPROOF_STAGE,
);
if (limited) return back("rate_limited");
let metadata;
try {
metadata = await discoverOidcMetadata(config);
} catch (err) {
getEvent()?.setError(err);
return back("oidc_failed");
}
const state = randomBytes(32).toString("base64url");
const nonce = randomBytes(32).toString("base64url");
const codeVerifier = randomBytes(64).toString("base64url");
const response = NextResponse.redirect(
buildAuthorizationUrl({
metadata,
config,
state,
nonce,
codeChallenge: s256Challenge(codeVerifier),
redirectUri: getOidcRedirectUri(),
reauthenticate: true,
}),
);
response.cookies.set(
OIDC_STATE_COOKIE,
encrypt(
JSON.stringify({
state,
nonce,
codeVerifier,
next,
intent: "reproof" as const,
reproofUserId: user.id,
reproofSessionId: session.id,
}),
),
{
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
path: OIDC_STATE_COOKIE_PATH,
},
);
return response;
});
@@ -5,6 +5,8 @@ import {
} from "@/lib/auth/passkey";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
RECENT_PROOF_MAX_AGE_MS,
REPROOF_REQUIRED_CODE,
isSecondFactorProof,
recordReproofFailure,
refundReproof,
@@ -59,6 +61,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess({ ...result, reauth: true });
}
// #1296 — an account whose only credential is its identity provider proves
// itself there (`GET /api/auth/oidc/reproof`), which stamps
// `Session.reproofAt`. `{ method: "oidc" }` asks this route to take that
// stamp as the proof: only where `oidc` is on the account's list (no second
// factor, a linked identity, a configured provider) and only inside the
// five-minute window. Like a password, it clears the MFA stamp rather than
// setting it.
if (Object.keys(body).length === 1 && body.method === "oidc") {
if (
!(await recentProofMethods(user)).includes("oidc") ||
!(await hasRecentReproof(session.id))
) {
annotate({
action: { name: "auth.reproof.required" },
meta: { stage: "passkey_enroll", method: "oidc" },
});
return apiError("Confirm it is you before adding a passkey", 401, {
errorCode: REPROOF_REQUIRED_CODE,
methods: await recentProofMethods(user),
});
}
return issueRegistrationOptions(user, session.id, null);
}
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) return proofRequired();
@@ -101,23 +127,48 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
await refundReproof(user.id);
// A strong existing factor refreshes the session's MFA stamp. Password proof
// deliberately clears it: registration is authorized by this single-use,
// session-bound challenge but cannot upgrade password-only authentication.
return issueRegistrationOptions(
user,
session.id,
parsed.data.method === "password" ? null : new Date(),
);
});
/** A password or provider re-proof on this session inside the window. */
async function hasRecentReproof(sessionId: string): Promise<boolean> {
const row = await prisma.session.findUnique({
where: { id: sessionId },
select: { reproofAt: true },
});
return (
row?.reproofAt != null &&
Date.now() - row.reproofAt.getTime() <= RECENT_PROOF_MAX_AGE_MS
);
}
/**
* A strong existing factor refreshes the session's MFA stamp. A password or
* provider proof deliberately clears it (`mfaVerifiedAt: null`): registration
* is authorized by this single-use, session-bound challenge but cannot upgrade
* first-factor authentication.
*/
async function issueRegistrationOptions(
user: { id: string; email: string | null; username: string },
sessionId: string,
mfaVerifiedAt: Date | null,
): Promise<Response> {
await prisma.session.update({
where: { id: session.id },
data: {
mfaVerifiedAt: parsed.data.method === "password" ? null : new Date(),
},
where: { id: sessionId },
data: { mfaVerifiedAt },
});
const { options, challengeId } = await createRegistrationOptions(
user.id,
user.email ?? user.username,
session.id,
sessionId,
);
annotate({ action: { name: "auth.passkey.register-options" } });
return apiSuccess({ options, challengeId });
});
}
@@ -43,7 +43,8 @@ async function confirmDocument(
const document = await prisma.inboundDocument.findFirst({
where: { id, userId: user.id, deletedAt: null },
select: { id: true },
// The two dates date a value that states none of its own.
select: { id: true, reportDate: true, documentDate: true },
});
if (!document) {
return apiError("Document not found", 404, {
@@ -94,10 +95,15 @@ async function confirmDocument(
try {
// v1.43 — an OBSERVATION may name the marker the person chose
// on the review screen; the label is then remembered as its alias.
const ref = await commitApprovedFact(user.id, fact, {
biomarkerId: decision.biomarkerId,
rememberAlias: decision.rememberAlias,
});
const ref = await commitApprovedFact(
user.id,
fact,
{
biomarkerId: decision.biomarkerId,
rememberAlias: decision.rememberAlias,
},
document,
);
await prisma.extractedFact.update({
where: { id: fact.id },
data: {
+39 -8
View File
@@ -23,6 +23,7 @@ import {
} from "@/lib/measurements/apple-health-mirror";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { bloodPressureSiblings } from "@/lib/measurements/blood-pressure-reading";
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
import { z } from "zod";
@@ -237,18 +238,45 @@ export const DELETE = apiHandler(
// (see `measurements/route.ts:100`), so the row is invisible to
// normal reads from this point on. A row that is already tombstoned
// re-bumps `syncVersion` harmlessly (idempotent re-delete).
await prisma.measurement.update({
where: { id },
data: {
deletedAt: new Date(),
syncVersion: { increment: 1 },
},
//
// A systolic or diastolic takes the rest of its reading with it (the
// other half and the cuff's pulse, `bloodPressureSiblings`), in the same
// transaction and with the same `deletedAt`, which is how the restore
// route knows to bring them back together. Each sibling bumps its own
// `syncVersion`, so a paired client sees every row's tombstone.
const deletedAt = new Date();
const siblings = await prisma.$transaction(async (tx) => {
const found = await bloodPressureSiblings(
tx,
user.id,
[existing],
"live",
);
await tx.measurement.update({
where: { id },
data: { deletedAt, syncVersion: { increment: 1 } },
});
if (found.length > 0) {
await tx.measurement.updateMany({
where: {
id: { in: found.map((s) => s.id) },
userId: user.id,
deletedAt: null,
},
data: { deletedAt, syncVersion: { increment: 1 } },
});
}
return found;
});
await auditLog("measurement.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { measurementId: id, type: existing.type },
details: {
measurementId: id,
type: existing.type,
readingRows: siblings.length,
},
});
annotate({
@@ -269,7 +297,10 @@ export const DELETE = apiHandler(
// the rollup row when the day's measurement count goes to zero).
await afterMeasurementMutation(
user.id,
[{ type: existing.type, measuredAt: existing.measuredAt }],
[
{ type: existing.type, measuredAt: existing.measuredAt },
...siblings.map((s) => ({ type: s.type, measuredAt: s.measuredAt })),
],
{ source: "manual" },
);
@@ -10,15 +10,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
const prisma = {
measurement: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
// The read and the write run in one transaction; the stub hands the
// callback the same mocked client.
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -113,8 +117,16 @@ describe("POST /api/measurements/bulk-delete", () => {
it("soft-deletes only owned, non-tombstoned rows and returns that count", async () => {
// Caller asked for 3 ids but only 2 are owned + live.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
{
id: "m1",
type: "WEIGHT",
measuredAt: new Date("2026-01-01T08:00:00Z"),
},
{
id: "m2",
type: "WEIGHT",
measuredAt: new Date("2026-01-01T20:00:00Z"),
},
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 2,
@@ -129,7 +141,9 @@ describe("POST /api/measurements/bulk-delete", () => {
// never 404s, it's just excluded from the count.
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect(call.where).toMatchObject({
id: { in: ["m1", "m2", "foreign"] },
// The write names the rows the scoped read found, never the
// forged id the caller sent.
id: { in: ["m1", "m2"] },
userId: "user-1",
deletedAt: null,
});
+23 -10
View File
@@ -7,6 +7,8 @@
*
* Soft-deletes (tombstones) every owned, not-already-deleted row in one
* `updateMany` — matching the single-DELETE iOS tombstone contract. A
* selected blood-pressure half takes the rest of its reading with it, as the
* single DELETE does, and `deleted` counts every row tombstoned. A
* forged / foreign id is a silent no-op (never a 404 existence leak): the
* mutation `where` pins `userId` so it only ever touches the caller's rows.
*
@@ -26,6 +28,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { bloodPressureSiblings } from "@/lib/measurements/blood-pressure-reading";
import type { MeasurementType } from "@/generated/prisma/client";
const MAX_IDS_PER_BATCH = 200;
@@ -73,17 +76,27 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
// a status-insight cache drop. Ownership-scoped + `deletedAt: null` so
// a forged / foreign / already-tombstoned id is excluded here exactly
// as it is from the mutation below — no existence leak.
const affected = await prisma.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
select: { type: true, measuredAt: true },
});
//
// A selected systolic or diastolic brings the rest of its reading (the
// other half and the cuff's pulse, `bloodPressureSiblings`) into the same
// statement, so the shared `deletedAt` lets the restore route bring the
// reading back whole.
const { affected, count } = await prisma.$transaction(async (tx) => {
const selected = await tx.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
select: { id: true, type: true, source: true, measuredAt: true },
});
const siblings = await bloodPressureSiblings(tx, user.id, selected, "live");
const rowIds = [...selected, ...siblings].map((row) => row.id);
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
// so a forged id for another user is silently a no-op; `deletedAt: null`
// keeps the count honest (an already-tombstoned id doesn't re-count).
const { count } = await prisma.measurement.updateMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
data: { deletedAt: now, syncVersion: { increment: 1 } },
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
// so a forged id for another user is silently a no-op; `deletedAt: null`
// keeps the count honest (an already-tombstoned id doesn't re-count).
const { count } = await tx.measurement.updateMany({
where: { id: { in: rowIds }, userId: user.id, deletedAt: null },
data: { deletedAt: now, syncVersion: { increment: 1 } },
});
return { affected: [...selected, ...siblings], count };
});
await auditLog("measurement.delete.bulk", {
@@ -10,15 +10,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
const prisma = {
measurement: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
// The read and the write run in one transaction; the stub hands the
// callback the same mocked client.
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -110,8 +114,16 @@ describe("POST /api/measurements/restore", () => {
it("un-tombstones only owned, tombstoned rows and returns that count", async () => {
// Caller asked for 3 ids but only 2 are owned + tombstoned.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
{
id: "m1",
type: "WEIGHT",
measuredAt: new Date("2026-01-01T08:00:00Z"),
},
{
id: "m2",
type: "WEIGHT",
measuredAt: new Date("2026-01-01T20:00:00Z"),
},
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 2,
@@ -126,7 +138,9 @@ describe("POST /api/measurements/restore", () => {
// forged / live id never 404s, it's just excluded from the count.
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect(call.where).toMatchObject({
id: { in: ["m1", "m2", "foreign"] },
// The write names the rows the scoped read found, never the
// forged id the caller sent.
id: { in: ["m1", "m2"] },
userId: "user-1",
deletedAt: { not: null },
});
+33 -10
View File
@@ -28,6 +28,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { bloodPressureSiblings } from "@/lib/measurements/blood-pressure-reading";
import type { MeasurementType } from "@/generated/prisma/client";
const MAX_IDS_PER_BATCH = 200;
@@ -73,17 +74,39 @@ async function postRestore(request: NextRequest): Promise<Response> {
// a status-insight cache drop. Ownership-scoped + `deletedAt != null`
// so a forged / foreign / never-deleted id is excluded here exactly as
// it is from the mutation below — no existence leak.
const affected = await prisma.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
select: { type: true, measuredAt: true },
});
//
// A blood-pressure half brings back the rows of its reading that were
// tombstoned in the same statement (identical `deletedAt`,
// `bloodPressureSiblings`), so an undo that names only the row the person
// tapped restores the reading the delete took. A row deleted on its own,
// at another moment, stays deleted.
const { affected, count } = await prisma.$transaction(async (tx) => {
const selected = await tx.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
select: {
id: true,
type: true,
source: true,
measuredAt: true,
deletedAt: true,
},
});
const siblings = await bloodPressureSiblings(
tx,
user.id,
selected,
"deletedWith",
);
const rowIds = [...selected, ...siblings].map((row) => row.id);
// Un-tombstone in one statement. The `where` pins `userId` so a forged
// id for another user is silently a no-op; `deletedAt: { not: null }`
// keeps the count honest (a live row doesn't re-count).
const { count } = await prisma.measurement.updateMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null, syncVersion: { increment: 1 } },
// Un-tombstone in one statement. The `where` pins `userId` so a forged
// id for another user is silently a no-op; `deletedAt: { not: null }`
// keeps the count honest (a live row doesn't re-count).
const { count } = await tx.measurement.updateMany({
where: { id: { in: rowIds }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null, syncVersion: { increment: 1 } },
});
return { affected: [...selected, ...siblings], count };
});
await auditLog("measurement.restore", {
+16 -58
View File
@@ -8,24 +8,10 @@ import {
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
import {
deleteGuardianAccountWithLifecycle,
LastManagedGuardianError,
} from "@/lib/managed-profiles/lifecycle";
import {
accountDeletionGraceDays,
accountDeletionPurgeAt,
} from "@/lib/account-deletion/grace";
import {
eraseAccount,
lockAccountForDeletion,
} from "@/lib/account-deletion/lifecycle";
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
import { requestAccountDeletion } from "@/lib/account-deletion/request";
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
import {
assertAnotherAdminStays,
LastAdminError,
} from "@/lib/auth/admin-roster";
import { LastAdminError } from "@/lib/auth/admin-roster";
export const dynamic = "force-dynamic";
@@ -104,53 +90,27 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
// the elevation would stay redeemable while the erasure was already running.
await auth.commitElevation();
const requestedAt = new Date();
const purgeAt = accountDeletionPurgeAt(
requestedAt,
accountDeletionGraceDays(),
);
let purgeAt: Date | null;
try {
await deleteGuardianAccountWithLifecycle(
({ purgeAt } = await requestAccountDeletion({
userId,
async (tx) => {
// Checked again under the roster lock: two admins deleting at the
// same moment must not each count the other.
if (user.role === "ADMIN") await assertAnotherAdminStays(tx, userId);
// The invariant has now passed, so this success event cannot survive
// a refusal. On an immediate erasure it is purged with the rest of the
// account's audit history; during a grace window it is the account's
// own record of the request.
await auditLog("user.account.delete", {
targetIsAdmin: user.role === "ADMIN",
requestedById: userId,
// The invariants have passed by the time this runs, so this success
// event cannot survive a refusal. On an immediate erasure it is purged
// with the rest of the account's audit history; during a grace window
// it is the account's own record of the request.
inTransaction: (tx, scheduledPurgeAt) =>
auditLog("user.account.delete", {
userId,
ipAddress: getClientIp(request),
details: {
username,
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
},
client: tx,
});
if (purgeAt) {
// Locked, not erased: credentials revoked in this transaction,
// the record untouched until the purge job reaches `purgeAt`.
await lockAccountForDeletion(tx, {
userId,
requestedById: userId,
requestedAt,
purgeAt,
});
return;
}
// No window: erase now, inside the same record lifecycle transaction
// so a refusal leaves every account state intact (sessions, the
// account's audit history — GDPR Art. 17 — the off-host purge
// request, then the row with everything cascading from it).
await eraseAccount(tx, userId, "account_deleted");
},
{ erasing: purgeAt === null },
);
}),
}));
} catch (error) {
if (error instanceof LastAdminError) {
return apiError("The last admin account cannot be deleted", 400);
@@ -165,8 +125,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
throw error;
}
if (!purgeAt) await kickOffhostPurge();
annotate({
action: { name: "settings.account.delete" },
meta: { username, deletion_scheduled: purgeAt !== null },
@@ -30,6 +30,8 @@ const sampleUsers = [
role: "ADMIN" as const,
passkeyCount: 2,
createdAt: "2026-04-01T08:00:00Z",
managedProfile: false,
deletion: null,
},
{
id: "u2",
@@ -38,6 +40,8 @@ const sampleUsers = [
role: "USER" as const,
passkeyCount: 1,
createdAt: "2026-04-15T08:00:00Z",
managedProfile: false,
deletion: null,
},
];
@@ -53,6 +57,10 @@ vi.mock("@tanstack/react-query", () => ({
}),
}));
vi.mock("@/lib/queries/use-privacy-summary", () => ({
useAccountDeletionGrace: () => ({ days: 14, pending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
@@ -115,6 +123,15 @@ describe("UserManagementSection — mobile responsive", () => {
expect(mobileSlice).toContain("Force logout");
});
it("offers deleting every account but the admin's own", () => {
const html = render();
// Two rows, each in the desktop table and the mobile list; only the
// other account carries the control.
const deletes = html.match(/data-slot="admin-user-delete"/g) ?? [];
expect(deletes).toHaveLength(2);
expect(html).toContain('aria-label="Delete account"');
});
it("uses smaller card padding on mobile (p-4 md:p-6)", () => {
const html = render();
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*md:p-6/);
+6 -4
View File
@@ -34,15 +34,17 @@ export interface AdminUser {
// the instance-wide default applies.
documentQuotaBytes: number | null;
passkeyCount: number;
/** A record its Guardians run; they delete it, an admin does not. */
managedProfile: boolean;
/**
* Set while the account is pending deletion: when the purge job
* erases it, and whether the account asked itself or a Guardian deleted
* the managed profile. The row offers "Undo deletion" until then.
* Set while the account is pending deletion: when the purge job erases
* it, and who asked (the account itself, a Guardian of the managed
* profile, or an admin). The row offers "Undo deletion" until then.
*/
deletion: {
requestedAt: string;
purgeAt: string | null;
requestedBy: "self" | "guardian";
requestedBy: "self" | "guardian" | "admin";
} | null;
}
@@ -0,0 +1,136 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiDelete } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAccountDeletionGrace } from "@/lib/queries/use-privacy-summary";
import {
recentProofErrorMessage,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import type { AdminUser } from "./_shared";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Whether the row offers deleting the account: never the admin's own (that is
* the account settings' deletion), never a managed profile (its Guardians
* delete it), and not one already pending deletion (the row offers the undo).
*/
export function canAdminDelete(user: AdminUser, currentUserId: string | null) {
return (
user.id !== currentUserId && !user.managedProfile && user.deletion === null
);
}
/**
* The day the purge job would erase an account deleted now, or null when the
* window is 0 and the erasure happens at once. The server stamps the real
* date; this is the one the dialog names before asking.
*/
export function deletionDateFrom(graceDays: number, now: Date): Date | null {
return graceDays > 0 ? new Date(now.getTime() + graceDays * DAY_MS) : null;
}
/**
* "Delete account" on a row of the admin users list
* (`DELETE /api/admin/users/{id}`), for the rows `canAdminDelete` allows.
*/
export function DeleteUserControl({
user,
currentUserId,
}: {
user: AdminUser;
currentUserId: string | null;
}) {
if (!canAdminDelete(user, currentUserId)) return null;
return <DeleteUserButton user={user} />;
}
/**
* The dialog names the date the account is erased and that it can be undone
* here until then, or, with no grace window, that it is erased at once. It
* waits for the window before it offers anything, so the sentence is never a
* guess. The fresh proof the route asks for is collected by the shared
* re-proof dialog.
*/
function DeleteUserButton({ user }: { user: AdminUser }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const grace = useAccountDeletionGrace();
const recentProof = useRecentProof();
const remove = useMutation({
mutationFn: () =>
recentProof.run(() =>
apiDelete<{ scheduledPurgeAt: string | null }>(
`/api/admin/users/${user.id}`,
{ confirm: "DELETE_ACCOUNT" },
),
),
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
toastWrittenOutcome(
"success",
result.scheduledPurgeAt
? t("admin.section.users.deleteUserScheduled", {
name: user.username,
date: formatDate(result.scheduledPurgeAt),
})
: t("admin.section.users.deleteUserDone", { name: user.username }),
);
},
onError: (err) => {
const message = recentProofErrorMessage(
err,
err instanceof Error && err.message
? err.message
: t("admin.section.users.deleteUserFailed"),
);
if (message) showErrorToast(message, { cause: err });
},
});
const purgeOn =
grace.days === null ? null : deletionDateFrom(grace.days, new Date());
return (
<>
{recentProof.dialog}
<ConfirmButton
slot="admin-user-delete"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-2 text-xs"
icon={<Trash2 className="h-3 w-3" aria-hidden="true" />}
label=""
ariaLabel={t("admin.section.users.deleteUser")}
disabled={grace.days === null}
title={t("admin.section.users.deleteUserTitle", {
name: user.username,
})}
body={
purgeOn
? t("admin.section.users.deleteUserBodyGrace", {
name: user.username,
date: formatDate(purgeOn),
})
: t("admin.section.users.deleteUserBodyNow", {
name: user.username,
})
}
confirmLabel={t("admin.section.users.deleteUserConfirm")}
pending={remove.isPending}
onConfirm={() => remove.mutate()}
destructive
/>
</>
);
}
@@ -13,6 +13,13 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { AdminUser } from "./_shared";
/** The undo dialog's sentence, by who asked for the deletion. */
const UNDO_BODY_KEYS = {
self: "admin.section.users.undoDeletionBody",
guardian: "admin.section.users.undoDeletionBodyGuardian",
admin: "admin.section.users.undoDeletionBodyAdmin",
} as const;
/**
* An account pending deletion, in the admin users list: the date the purge
* job erases it, and the one action that stops it.
@@ -64,12 +71,9 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
title={t("admin.section.users.undoDeletionTitle", {
name: user.username,
})}
body={t(
user.deletion.requestedBy === "guardian"
? "admin.section.users.undoDeletionBodyGuardian"
: "admin.section.users.undoDeletionBody",
{ name: user.username },
)}
body={t(UNDO_BODY_KEYS[user.deletion.requestedBy], {
name: user.username,
})}
confirmLabel={t("admin.section.users.undoDeletion")}
pending={undo.isPending}
onConfirm={() => undo.mutate()}
@@ -40,6 +40,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
import { PendingDeletionControls } from "./pending-deletion-controls";
import { DeleteUserControl } from "./delete-user-control";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
@@ -55,6 +56,13 @@ import {
*/
type UserFilter = "all" | "admin" | "user";
/** The quota override as the edit form's gigabyte field shows it. */
function quotaGbInput(bytes: number | null): string {
if (bytes === null) return "";
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
return String(Math.round((bytes / 1_073_741_824) * 10) / 10);
}
export function UserManagementSection() {
const { t } = useTranslations();
const currentUserId = useAuth().user?.id ?? "";
@@ -160,12 +168,7 @@ export function UserManagementSection() {
setEditingUser(u);
setEditUsername(u.username);
setEditEmail(u.email ?? "");
setEditQuotaGb(
u.documentQuotaBytes === null
? ""
: // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
);
setEditQuotaGb(quotaGbInput(u.documentQuotaBytes));
}
function startReset(u: AdminUser) {
@@ -229,7 +232,7 @@ export function UserManagementSection() {
{u.mfaEnforced ? t("admin.mfaEnforcedOn") : t("admin.mfaEnforcedOff")}
<span className="sr-only">, {u.username}</span>
</Button>
{/* The three icon tools travel as one group: on a phone the row wraps
{/* The icon tools travel as one group: on a phone the row wraps
between the labelled toggles and the tools, never leaving the
sign-out icon alone on a line. */}
<span className="ml-auto flex items-center gap-1">
@@ -272,6 +275,7 @@ export function UserManagementSection() {
>
<LogOut className="h-3 w-3" aria-hidden="true" />
</Button>
<DeleteUserControl user={u} currentUserId={currentUserId} />
</span>
</>
);
@@ -0,0 +1,40 @@
"use client";
import { useEffect } from "react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useTranslations } from "@/lib/i18n/context";
import { dropParams, writeUrlState } from "@/lib/url-state";
/**
* The outcome a confirmation at the identity provider came back with
* (`?reproof=done|failed`, set by the OIDC callback). Pure, so the parsing is
* testable without a browser.
*/
export function readReproofReturn(search: string): "done" | "failed" | null {
const raw = new URLSearchParams(search).get("reproof");
return raw === "done" || raw === "failed" ? raw : null;
}
/**
* Says once how the confirmation at the identity provider went, then takes the
* marker off the address so a reload does not say it again. The browser lands
* on the page it left, which is where the person repeats what they were
* about to do.
*/
export function OidcReproofReturnBridge() {
const { t } = useTranslations();
useEffect(() => {
const outcome = readReproofReturn(window.location.search);
if (!outcome) return;
writeUrlState(dropParams("reproof"), { mode: "replace", state: "keep" });
toastWrittenOutcome(
outcome === "done" ? "success" : "failed",
outcome === "done"
? t("settings.reproof.oidcDone")
: t("settings.reproof.oidcFailed"),
);
// A later run (a locale switch) finds no marker and does nothing.
}, [t]);
return null;
}
@@ -43,7 +43,7 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
import { DashboardHeader } from "../dashboard-header";
import { DashboardHeader, greetingLine } from "../dashboard-header";
import {
CAPTURE_KIND_ORDER,
visibleCaptureKinds,
@@ -58,12 +58,73 @@ function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
);
}
describe("greetingLine", () => {
const now = Date.parse("2026-10-29T09:00:00Z");
const base = { greeting: "Hello", nowMs: now };
it("welcomes an account in its first day, without a 'back'", () => {
expect(
greetingLine({
...base,
known: true,
name: "tester",
createdAt: "2026-10-29T08:50:00Z",
}),
).toEqual({
key: "dashboard.welcomeNewWithName",
values: { name: "tester" },
});
expect(
greetingLine({
...base,
known: true,
name: null,
createdAt: "2026-10-28T10:00:00Z",
}).key,
).toBe("dashboard.welcomeNew");
});
it("welcomes back an account older than a day", () => {
expect(
greetingLine({
...base,
known: true,
name: "tester",
createdAt: "2026-10-20T08:00:00Z",
}),
).toEqual({
key: "dashboard.welcomeBackWithName",
values: { greeting: "Hello", name: "tester" },
});
});
it("welcomes back when an older server does not say when the account began", () => {
expect(
greetingLine({ ...base, known: true, name: null, createdAt: undefined })
.key,
).toBe("dashboard.welcomeBack");
});
it("says only the greeting while the account is not known", () => {
expect(
greetingLine({
...base,
known: false,
name: "tester",
createdAt: "2026-10-29T08:50:00Z",
}),
).toEqual({ key: "dashboard.greetingOnly", values: { greeting: "Hello" } });
});
});
describe("<DashboardHeader> — greeting", () => {
it("renders the greeting line under the title", () => {
const html = renderSSR(<DashboardHeader onQuickEntry={() => undefined} />);
expect(html).toContain('data-slot="dashboard-header-greeting"');
// SSR pass: name-less fallback (personalises post-hydration only).
expect(html).toContain("willkommen zurück.");
// SSR pass: the time-of-day greeting alone. Nothing says "welcome
// back" before the client knows the account is not new.
expect(html).toContain("Hallo.");
expect(html).not.toContain("willkommen zurück");
// Reserved line box keeps the header height stable through the
// post-hydration personalisation swap.
expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/);
+47 -10
View File
@@ -42,6 +42,43 @@ import {
} from "@/components/layout/capture-picker";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
const FIRST_DAY_MS = 24 * 60 * 60 * 1000;
/**
* The greeting line, as an i18n key and its values.
*
* Until the account is known (the SSR pass and the hydration render) it is the
* time-of-day greeting alone, so nothing claims "welcome back" before the
* client knows it is true. An account in its first day (`createdAt` from
* `/api/auth/me`) is welcomed, not welcomed back: the setup flow lands a new
* account here minutes after it was created. Any other account, and one whose
* server does not say when it was created, is welcomed back.
*/
export function greetingLine(input: {
greeting: string;
known: boolean;
name: string | null;
createdAt: string | null | undefined;
/** The clock, for tests; the moment of the call otherwise. */
nowMs?: number;
}): { key: string; values: Record<string, string> } {
const { greeting, name } = input;
const nowMs = input.nowMs ?? Date.now();
if (!input.known) {
return { key: "dashboard.greetingOnly", values: { greeting } };
}
const created = input.createdAt ? Date.parse(input.createdAt) : Number.NaN;
const isNew = Number.isFinite(created) && nowMs - created < FIRST_DAY_MS;
if (isNew) {
return name
? { key: "dashboard.welcomeNewWithName", values: { name } }
: { key: "dashboard.welcomeNew", values: {} };
}
return name
? { key: "dashboard.welcomeBackWithName", values: { greeting, name } }
: { key: "dashboard.welcomeBack", values: { greeting } };
}
export function DashboardHeader({
onQuickEntry,
}: {
@@ -103,22 +140,22 @@ export function DashboardHeader({
: hour >= 12 && hour < 18
? t("dashboard.greeting.day")
: t("dashboard.greeting.evening");
const welcomeText =
mounted && user?.username && user.username.trim().length > 0
? t("dashboard.welcomeBackWithName", {
greeting: timeGreeting,
name: user.username,
})
: t("dashboard.welcomeBack", { greeting: timeGreeting });
const greeting = greetingLine({
greeting: timeGreeting,
known: mounted && user != null,
name: user?.username?.trim() ? user.username : null,
createdAt: user?.createdAt,
});
const welcomeText = t(greeting.key, greeting.values);
return (
<PageHeader
title={t("dashboard.title")}
description={
/* `min-h-5` reserves the greeting's line box from the SSR
pass on: the text personalises (name appended) on the first
client re-render after hydration, and the reserved line
keeps the header from collapsing/growing around that swap. */
pass on: the text personalises (name and welcome appended) on
the first client re-render after hydration, and the reserved
line keeps the header from collapsing/growing around that swap. */
<span
data-slot="dashboard-header-greeting"
className="block min-h-5 truncate"
@@ -895,7 +895,7 @@ export interface SendCoachMessageParams {
conversationId?: string;
message: string;
prefill?: string;
locale?: "en" | "de";
locale?: import("@/lib/i18n/config").Locale;
/**
* v1.4.20.1 — optional snapshot scope (per-source toggles + window)
* driven by the sources-rail picker. When omitted the server falls
@@ -0,0 +1,71 @@
/**
* The language reaches the account only when there is an account.
*
* The provider used to PUT the locale on every mount, signed in or not. A
* signed-out page got a 401, the once-per-tab marker was never set, and every
* signed-out page load sent the refused write again. The write now belongs to
* `LocaleServerSyncBridge`, which waits for the account payload without
* fetching it and then sends the choice once.
*/
import { describe, expect, it } from "vitest";
import { queryKeys } from "@/lib/query-keys";
import { authUser, renderWithApp } from "../../../../tests/browser/render";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
import { LocaleServerSyncBridge } from "../locale-server-sync-bridge";
const LOCALE_PUT = "/api/auth/me/locale";
async function settle(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 150));
}
describe("LocaleServerSyncBridge", () => {
it("sends nothing while nobody is signed in, and never asks who is", async () => {
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
await renderWithApp(<LocaleServerSyncBridge />, {
user: null,
locale: "de",
});
await settle();
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(0);
expect(fetchStub.calls("GET", "/api/auth/me")).toHaveLength(0);
});
it("sends the choice made while signed out once an account appears", async () => {
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
const { queryClient } = await renderWithApp(<LocaleServerSyncBridge />, {
user: null,
locale: "de",
});
await settle();
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(0);
queryClient.setQueryData(
queryKeys.authMe(),
authUser({ id: "account-signed-in-later" }),
);
await expect.poll(() => fetchStub.calls("PUT", LOCALE_PUT).length).toBe(1);
expect(fetchStub.calls("PUT", LOCALE_PUT)[0].body).toEqual({
locale: "de",
});
});
it("writes once per tab for the same account and language", async () => {
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
const first = await renderWithApp(<LocaleServerSyncBridge />, {
user: { id: "account-already-synced" },
locale: "en",
});
await expect.poll(() => fetchStub.calls("PUT", LOCALE_PUT).length).toBe(1);
await first.screen.unmount();
await renderWithApp(<LocaleServerSyncBridge />, {
user: { id: "account-already-synced" },
locale: "en",
});
await settle();
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(1);
});
});
@@ -0,0 +1,39 @@
"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchMe } from "@/hooks/use-auth";
import { persistLocaleToServer, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* Keeps `User.locale` in step with the language on screen, for a signed-in
* account only, so the proactive Coach nudge and the other cookie-blind
* background paths write in the language the person reads.
*
* It watches the account payload without fetching it (`enabled: false`): the
* shell already reads `/api/auth/me` on every signed-in page, and a signed-out
* page has no account to write to. Fetching here would trade the refused
* locale PUT for a refused account read on every signed-out load. Same key
* and same fetcher as `useAuth`, so the cache entry is shared, not shadowed.
*
* Fires when an account appears (right after signing in, carrying the choice
* made on the sign-in page) and on every switch; `persistLocaleToServer`
* skips a value this tab already wrote for this account.
*/
export function LocaleServerSyncBridge() {
const { locale } = useTranslations();
const { data: account } = useQuery({
queryKey: queryKeys.authMe(),
queryFn: fetchMe,
enabled: false,
});
const accountId = account?.id ?? null;
useEffect(() => {
if (accountId) persistLocaleToServer(locale, accountId);
}, [locale, accountId]);
return null;
}
+4
View File
@@ -21,6 +21,8 @@ import { VersionPoller } from "@/components/version-poller";
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
import { RecordSessionFenceBridge } from "@/components/layout/record-session-fence-bridge";
import { OidcReproofReturnBridge } from "@/components/auth/oidc-reproof-return";
import { LocaleServerSyncBridge } from "@/components/layout/locale-server-sync-bridge";
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
@@ -432,6 +434,8 @@ export function Providers({
in its own account, rather than asking every call site to. */}
<SharedRecordGrantLossBridge />
<RecordSessionFenceBridge />
<OidcReproofReturnBridge />
<LocaleServerSyncBridge />
{children}
<AppToaster />
<KeyboardInsetBridge />
@@ -0,0 +1,78 @@
/**
* The web side of confirming at the identity provider (#1296): the dialogs
* offer it when the server names it, the round trip starts from and returns
* to the page the person is on, and the outcome marker is read once.
*/
import { describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import { oidcReproofStartUrl } from "../existing-factor-reauth-dialog";
import { passkeyProofChoices } from "../passkey-list-section";
import { createRecentProofFlow } from "../recent-proof-flow";
import { readReproofReturn } from "@/components/auth/oidc-reproof-return";
describe("passkeyProofChoices", () => {
it("offers only what the account can give once its list is known", () => {
expect(passkeyProofChoices(["oidc"])).toEqual(["oidc"]);
expect(passkeyProofChoices(["password", "oidc", "passkey"])).toEqual([
"password",
"oidc",
"passkey",
]);
expect(passkeyProofChoices(["totp", "something-new"])).toEqual(["totp"]);
});
it("falls back to the four body proofs while the list is loading", () => {
expect(passkeyProofChoices(undefined)).toEqual([
"password",
"totp",
"passkey",
"webauthn",
]);
});
});
describe("oidcReproofStartUrl", () => {
it("names the current page as the way back, without an old outcome", () => {
const href = oidcReproofStartUrl({
pathname: "/settings/api",
search: "?tab=mcp&reproof=failed",
});
const url = new URL(href, "https://healthlog.example.test");
expect(url.pathname).toBe("/api/auth/oidc/reproof");
expect(url.searchParams.get("next")).toBe("/settings/api?tab=mcp");
});
});
describe("readReproofReturn", () => {
it("reads a known outcome off the query string", () => {
expect(readReproofReturn("?tab=mcp&reproof=done")).toBe("done");
expect(readReproofReturn("?reproof=failed")).toBe("failed");
});
it("ignores an address without a known outcome", () => {
expect(readReproofReturn("")).toBeNull();
expect(readReproofReturn("?reproof=maybe")).toBeNull();
});
});
describe("the re-proof dialog flow", () => {
it("opens with the identity provider when that is the account's only way", async () => {
const setMethods = vi.fn();
const flow = createRecentProofFlow({
view: { setMethods, setPending: vi.fn(), setError: vi.fn() },
submit: vi.fn(),
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
});
void flow
.run(async () => {
throw new ApiError("Confirm it is you", 401, {
errorCode: "auth.reproof.required",
methods: ["oidc"],
});
})
.catch(() => {});
await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"]));
flow.cancel();
});
});
@@ -19,7 +19,30 @@ import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
export type ReauthMethod = "password" | "totp" | "passkey" | "webauthn";
export type ReauthMethod =
"password" | "oidc" | "totp" | "passkey" | "webauthn";
/**
* Where the browser goes to confirm at the identity provider: the re-proof
* start, told to come back to the page it is on now. A `reproof` outcome left
* on the current address from an earlier round trip is dropped, so the page
* does not come back announcing the old one.
*/
export function oidcReproofStartUrl(current: {
pathname: string;
search: string;
}): string {
const params = new URLSearchParams(current.search);
params.delete("reproof");
const query = params.toString();
const next = `${current.pathname}${query ? `?${query}` : ""}`;
return `/api/auth/oidc/reproof?${new URLSearchParams({ next }).toString()}`;
}
/** Leave for the identity provider. The page unloads; nothing resolves. */
export function startOidcReproof(): void {
window.location.assign(oidcReproofStartUrl(window.location));
}
/** The body the enrollment routes accept as a fresh existing-factor proof. */
export type ExistingFactorProof =
@@ -46,23 +69,19 @@ export function isReproofRequired(err: unknown): boolean {
const REAUTH_METHODS: readonly ReauthMethod[] = [
"password",
"oidc",
"totp",
"passkey",
"webauthn",
];
/**
* The proofs the refusal says this account can give, or null when it names
* none. On an account with a second factor the server leaves the password out,
* so the dialog does not offer a choice that can only be refused.
*/
/**
* The refusal says the account holds nothing it could re-prove here: an
* account that signs in through an identity provider only, with no password,
* passkey or second factor. A dialog would only offer it a password it does
* not have, so the callers say "sign in again" instead — a fresh sign-in is
* the proof such an account can give (`Session.createdAt` within five
* minutes).
* The refusal says the account holds nothing it could re-prove here: no
* password, passkey or second factor, and no identity provider to confirm at
* (an account whose provider the operator has since switched off). A dialog
* would only offer it a password it does not have, so the callers say "sign
* in again" instead — a fresh sign-in is the proof such an account can give
* (`Session.createdAt` within five minutes).
*/
export function nothingToReprove(err: unknown): boolean {
return (
@@ -72,6 +91,11 @@ export function nothingToReprove(err: unknown): boolean {
);
}
/**
* The proofs the refusal says this account can give, or null when it names
* none. On an account with a second factor the server leaves the password out,
* so the dialog does not offer a choice that can only be refused.
*/
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
if (!(err instanceof ApiError)) return null;
const raw = err.meta?.methods;
@@ -87,7 +111,9 @@ export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
* new factor is added. A passkey or security-key assertion begins at the
* cookie-only `/api/auth/passkey/register-options` re-proof arm, which issues
* an assertion challenge and nothing else; the resulting proof is handed to
* `onProof`, which retries the enrollment call with it.
* `onProof`, which retries the enrollment call with it. The identity provider
* is not a proof in the body: choosing it leaves the page for the provider and
* comes back to it, and the action is then repeated by hand.
*/
export function ExistingFactorReauthDialog({
open,
@@ -117,6 +143,11 @@ export function ExistingFactorReauthDialog({
async function submit() {
setAssertionError(null);
if (method === "oidc") {
setAsserting(true);
startOidcReproof();
return;
}
if (method === "password") {
onProof({ method, password: secret });
return;
@@ -152,6 +183,7 @@ export function ExistingFactorReauthDialog({
const labels: Record<ReauthMethod, string> = {
password: t("settings.passkeyReauth.methods.password"),
oidc: t("settings.passkeyReauth.methods.oidc"),
totp: t("settings.passkeyReauth.methods.totp"),
passkey: t("settings.passkeyReauth.methods.passkey"),
webauthn: t("settings.passkeyReauth.methods.webauthn"),
@@ -223,6 +255,10 @@ export function ExistingFactorReauthDialog({
/>
)}
{method === "oidc" && (
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
)}
{shown && (
<div
role="alert"
@@ -253,7 +289,9 @@ export function ExistingFactorReauthDialog({
{busy && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.passkeyReauth.verifyAndContinue")}
{method === "oidc"
? t("settings.passkeyReauth.continueToProvider")
: t("settings.passkeyReauth.verifyAndContinue")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -20,6 +20,7 @@ interface MfaStatus {
recoveryCodesRemaining: number;
webauthn: WebauthnKeyInfo[];
passkeyNudgeDismissed: boolean;
proofMethods: string[];
}
interface PasskeyInfo {
@@ -71,7 +72,10 @@ export function SecuritySection() {
totpEnabled={status.totp.enabled}
/>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<PasskeyListSection
isAuthenticated={isAuthenticated}
proofMethods={status.proofMethods}
/>
</>
)}
@@ -45,6 +45,11 @@ import {
} from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
import { describeStepUp } from "./security-keys-card";
import {
isReproofRequired,
startOidcReproof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
interface PasskeyInfo {
@@ -56,17 +61,102 @@ interface PasskeyInfo {
lastUsedAt: string | null;
}
type ReauthMethod = "password" | "passkey" | "totp" | "webauthn";
type ExistingFactorProof =
| { method: "password"; password: string }
| { method: "totp"; code: string }
| { method: "oidc" }
| {
method: "passkey" | "webauthn";
challengeId: string;
credential: unknown;
};
/** What the card offers while the account's own list has not arrived. */
const FALLBACK_METHODS: readonly ReauthMethod[] = [
"password",
"totp",
"passkey",
"webauthn",
];
/**
* The proofs to offer before adding a passkey: the account's own list when the
* status read has it, so an account without a password is not handed a
* password field, and the four body proofs otherwise.
*/
export function passkeyProofChoices(
proofMethods: readonly string[] | undefined,
): ReauthMethod[] {
if (!proofMethods) return [...FALLBACK_METHODS];
const known: readonly ReauthMethod[] = [
"password",
"oidc",
"totp",
"passkey",
"webauthn",
];
return proofMethods.filter((m): m is ReauthMethod =>
known.includes(m as ReauthMethod),
);
}
/**
* Prove an existing factor, then run the registration ceremony. The proof
* finishes before the first registration challenge is requested, so a
* cancelled or failed proof leaves no enrollable challenge behind.
*/
async function addPasskey(method: ReauthMethod, secret: string): Promise<void> {
const webauthn = await import("@simplewebauthn/browser");
let freshFactor: ExistingFactorProof;
if (method === "oidc") {
// No secret: the proof is a provider re-proof already on the session.
freshFactor = { method };
} else if (method === "password") {
freshFactor = { method, password: secret };
} else if (method === "totp") {
freshFactor = { method, code: secret };
} else {
const assertionChallenge = await apiPost<{
options: Parameters<
typeof webauthn.startAuthentication
>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options", { method });
const credential = await webauthn.startAuthentication({
optionsJSON: assertionChallenge.options,
});
freshFactor = {
method,
challengeId: assertionChallenge.challengeId,
credential,
};
}
let begun: {
options: Parameters<typeof webauthn.startRegistration>[0]["optionsJSON"];
challengeId: string;
};
try {
begun = await apiPost("/api/auth/passkey/register-options", freshFactor);
} catch (err) {
// No provider re-proof on the session yet: go and get one. The page
// unloads and comes back here, so the mutation stays pending rather
// than flashing an error on the way out.
if (method !== "oidc" || !isReproofRequired(err)) throw err;
startOidcReproof();
return new Promise<never>(() => {});
}
const { options, challengeId } = begun;
const credential = await webauthn.startRegistration({
optionsJSON: options,
});
await apiPost("/api/auth/passkey/register-verify", {
challengeId,
credential,
});
}
/**
* Primary-passkey management: list (name, device, backup, created, last used),
* add (registration ceremony), rename, and delete. The passwordless-primary
@@ -74,8 +164,11 @@ type ExistingFactorProof =
*/
export function PasskeyListSection({
isAuthenticated,
proofMethods,
}: {
isAuthenticated: boolean;
/** `proofMethods` from `GET /api/auth/me/mfa`. */
proofMethods?: readonly string[];
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -83,7 +176,10 @@ export function PasskeyListSection({
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [reauthOpen, setReauthOpen] = useState(false);
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>("password");
const choices = passkeyProofChoices(proofMethods);
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>(
choices[0] ?? "password",
);
const [reauthSecret, setReauthSecret] = useState("");
const { data: passkeys } = useQuery({
@@ -93,56 +189,18 @@ export function PasskeyListSection({
});
const add = useMutation({
mutationFn: async ({
mutationFn: ({
method,
secret,
}: {
method: ReauthMethod;
secret: string;
}) => {
const webauthn = await import("@simplewebauthn/browser");
let freshFactor: ExistingFactorProof;
if (method === "password") {
freshFactor = { method, password: secret };
} else if (method === "totp") {
freshFactor = { method, code: secret };
} else {
const assertionChallenge = await apiPost<{
options: Parameters<
typeof webauthn.startAuthentication
>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options", { method });
const credential = await webauthn.startAuthentication({
optionsJSON: assertionChallenge.options,
});
freshFactor = {
method,
challengeId: assertionChallenge.challengeId,
credential,
};
}
const { options, challengeId } = await apiPost<{
options: Parameters<
typeof webauthn.startRegistration
>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options", freshFactor);
const credential = await webauthn.startRegistration({
optionsJSON: options,
});
await apiPost("/api/auth/passkey/register-verify", {
challengeId,
credential,
});
},
}) => addPasskey(method, secret),
onSuccess: () => {
setMsg(null);
setReauthOpen(false);
setReauthSecret("");
setReauthMethod("password");
setReauthMethod(choices[0] ?? "password");
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
toastWrittenOutcome("success", t("settings.passkeyAddedToast"));
},
@@ -195,6 +253,14 @@ export function PasskeyListSection({
},
});
const methodLabels: Record<ReauthMethod, string> = {
password: t("settings.passkeyReauth.methods.password"),
oidc: t("settings.passkeyReauth.methods.oidc"),
totp: t("settings.passkeyReauth.methods.totp"),
passkey: t("settings.passkeyReauth.methods.passkey"),
webauthn: t("settings.passkeyReauth.methods.webauthn"),
};
const DEVICE_TYPE_LABELS: Record<string, string> = {
singleDevice: t("settings.singleDevice"),
multiDevice: t("settings.multiDevice"),
@@ -350,7 +416,14 @@ export function PasskeyListSection({
onOpenChange={(open) => {
if (add.isPending) return;
setReauthOpen(open);
if (open) setMsg(null);
if (open) {
setMsg(null);
// The choice the account's list leads with, in case the list
// arrived after the card first rendered.
if (!choices.includes(reauthMethod)) {
setReauthMethod(choices[0] ?? "password");
}
}
}}
>
<SettingsCardActions>
@@ -384,14 +457,7 @@ export function PasskeyListSection({
role="group"
aria-label={t("settings.passkeyReauth.methodLabel")}
>
{(
[
["password", t("settings.passkeyReauth.methods.password")],
["totp", t("settings.passkeyReauth.methods.totp")],
["passkey", t("settings.passkeyReauth.methods.passkey")],
["webauthn", t("settings.passkeyReauth.methods.webauthn")],
] satisfies Array<[ReauthMethod, string]>
).map(([method, label]) => (
{choices.map((method) => (
<Button
key={method}
type="button"
@@ -407,11 +473,15 @@ export function PasskeyListSection({
setMsg(null);
}}
>
{label}
{methodLabels[method]}
</Button>
))}
</div>
{reauthMethod === "oidc" && (
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
)}
{reauthMethod === "password" && (
<Input
type="password"
@@ -14,6 +14,7 @@ import {
const KNOWN_METHODS: readonly ReauthMethod[] = [
"password",
"oidc",
"totp",
"webauthn",
"passkey",
@@ -93,7 +94,9 @@ export function createRecentProofFlow(deps: {
if (!isProofRefusal(err)) throw err;
const offered = methodsFrom(err as ApiError);
// Nothing to re-prove in place: an account that signs in through an
// identity provider only. A fresh sign-in is the proof it can give.
// identity provider the instance no longer offers. A fresh sign-in is
// the proof it can give. One that can still reach its provider is
// offered `oidc` instead, which leaves the page and comes back to it.
if (offered.length === 0) {
throw new ApiError(deps.messages().signInAgain, 401, {
errorCode: "auth.reproof.sign_in_again",
+5
View File
@@ -65,6 +65,11 @@ export type AuthTourProgress = TourProgress;
export interface AuthUser {
id: string;
username: string;
/**
* When the account was created (ISO). Absent from an older server image,
* which every reader treats as an established account.
*/
createdAt?: string;
email: string | null;
role: string;
heightCm: number | null;
+81
View File
@@ -0,0 +1,81 @@
/**
* Asking for an account to be deleted: the one path the account's own
* deletion (`DELETE /api/settings/account`) and an admin's deletion of
* another account (`DELETE /api/admin/users/{id}`) both take.
*
* With a grace window (`ACCOUNT_DELETION_GRACE_DAYS`) the account is locked:
* every credential is revoked at once, the record stays exactly as it is, and
* the purge job erases it when the window ends; an admin can undo it until
* then. With the window at 0 it is erased here, in the same transaction.
*
* The Guardian invariant (an account may not leave a managed profile without
* a Guardian) and the admin roster (the last admin who can sign in stays) are
* decided inside the transaction, under their locks, and refuse by throwing
* `LastManagedGuardianError` or `LastAdminError`.
*/
import type { Prisma } from "@/generated/prisma/client";
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
import { deleteGuardianAccountWithLifecycle } from "@/lib/managed-profiles/lifecycle";
import { assertAnotherAdminStays } from "@/lib/auth/admin-roster";
import {
accountDeletionGraceDays,
accountDeletionPurgeAt,
} from "@/lib/account-deletion/grace";
import {
eraseAccount,
lockAccountForDeletion,
} from "@/lib/account-deletion/lifecycle";
type Tx = Prisma.TransactionClient;
export async function requestAccountDeletion(input: {
userId: string;
/** Whether the account being deleted is an admin (the roster check). */
targetIsAdmin: boolean;
/** Who asked: the account itself, or the admin acting on it. */
requestedById: string;
/**
* Runs first inside the transaction, before anything is written: the place
* for a refusal that must be decided under the same locks (throw), and for
* the audit rows, which then cannot outlive a refusal.
*/
inTransaction: (tx: Tx, purgeAt: Date | null) => Promise<void>;
}): Promise<{ purgeAt: Date | null }> {
const requestedAt = new Date();
const purgeAt = accountDeletionPurgeAt(
requestedAt,
accountDeletionGraceDays(),
);
await deleteGuardianAccountWithLifecycle(
input.userId,
async (tx) => {
// Checked again under the roster lock: two admins deleting at the same
// moment must not each count the other.
if (input.targetIsAdmin) await assertAnotherAdminStays(tx, input.userId);
await input.inTransaction(tx, purgeAt);
if (purgeAt) {
// Locked, not erased: credentials revoked in this transaction, the
// record untouched until the purge job reaches `purgeAt`.
await lockAccountForDeletion(tx, {
userId: input.userId,
requestedById: input.requestedById,
requestedAt,
purgeAt,
});
return;
}
// No window: erase now, inside the same record lifecycle transaction so
// a refusal leaves every account state intact (sessions, the account's
// audit history — GDPR Art. 17 — the off-host purge request, then the
// row with everything cascading from it).
await eraseAccount(tx, input.userId, "account_deleted");
},
{ erasing: purgeAt === null },
);
if (!purgeAt) await kickOffhostPurge();
return { purgeAt };
}
@@ -137,6 +137,49 @@ describe("Codex reasoning request", () => {
expect(body.include).toEqual([]);
expect(result.reasoning?.downgradedTo).toBeUndefined();
});
it("on a slug that refuses none, steps up to its lowest effort and stays on the slug", async () => {
// gpt-6.1-sol, a rung on the default list, refuses "none" (seen 2026-10-24).
// That refusal is about the effort, not the model: no walk to the next slug.
const refusal = JSON.stringify({
error: {
message:
"Unsupported value: 'none' is not supported with the 'gpt-6.1-sol' model. Supported values are: 'low', 'medium', 'high', 'xhigh', and 'max'.",
type: "invalid_request_error",
param: "reasoning.effort",
code: "unsupported_value",
},
});
const mock = vi
.fn()
.mockResolvedValueOnce(errorResponse(400, refusal))
.mockImplementation(async () =>
sseResponse(fixture("codex-after-tool.sse")),
);
vi.stubGlobal("fetch", mock);
const c = new CodexClient({
accessToken: "test-token",
accountId: "acct-test",
onTokenRefresh: vi
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
slugChain: ["gpt-6.1-sol", "gpt-6-sol"],
});
await c.generateCompletion(
params({ reasoning: { effort: "off", summaries: false } }),
);
expect(sentBody(mock, 0).reasoning).toEqual({ effort: "none" });
expect(sentBody(mock, 1).model).toBe("gpt-6.1-sol");
expect(sentBody(mock, 1).reasoning).toEqual({ effort: "low" });
await c.generateCompletion(
params({ reasoning: { effort: "off", summaries: false } }),
);
expect(mock).toHaveBeenCalledTimes(3);
expect(sentBody(mock, 2).model).toBe("gpt-6.1-sol");
expect(sentBody(mock, 2).reasoning).toEqual({ effort: "low" });
});
});
describe("Codex reasoning stream", () => {
@@ -91,6 +91,48 @@ describe("Codex slug fallback chain", () => {
clearCodexSlugCache();
});
it("an account without the head of the default list walks on to the next rung", async () => {
const origModel = process.env.CODEX_MODEL;
const origChain = process.env.CODEX_MODEL_FALLBACK_CHAIN;
delete process.env.CODEX_MODEL;
delete process.env.CODEX_MODEL_FALLBACK_CHAIN;
try {
// The exact refusal a ChatGPT sign-in gets for a retired model.
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
rejectionResponse(
400,
'{"detail":"The \'gpt-5.6-terra\' model is not supported when using Codex with a ChatGPT account.","error":{"type":"invalid_request_error","code":"model_not_found"}}',
),
)
.mockResolvedValueOnce(sseSuccessResponse());
vi.stubGlobal("fetch", mockFetch);
const client = new CodexClient({
accessToken: "test",
accountId: "acct",
onTokenRefresh,
});
await client.generateCompletion(
singleUserTurn({ system: "s", user: "u" }),
);
expect(client.getLastDiagnostics()?.attempted).toEqual([
"gpt-5.6-terra",
"gpt-6-sol",
]);
expect(client.getLastDiagnostics()?.workingSlug).toBe("gpt-6-sol");
expect(getCachedCodexSlug()).toBe("gpt-6-sol");
} finally {
if (origModel === undefined) delete process.env.CODEX_MODEL;
else process.env.CODEX_MODEL = origModel;
if (origChain === undefined)
delete process.env.CODEX_MODEL_FALLBACK_CHAIN;
else process.env.CODEX_MODEL_FALLBACK_CHAIN = origChain;
}
});
it("first slug succeeds — uses head-of-chain and caches it", async () => {
const mockFetch = vi.fn().mockResolvedValue(sseSuccessResponse());
vi.stubGlobal("fetch", mockFetch);
@@ -340,6 +382,14 @@ describe("Codex slug fallback chain", () => {
).toBe(true);
expect(helper(400, '{"error":{"code":"model_not_found"}}')).toBe(true);
expect(helper(400, "model gpt-5 does not exist")).toBe(true);
// The refusal a ChatGPT sign-in got for gpt-5.5 after its retirement
// (seen 2026-10-24).
expect(
helper(
400,
'{"detail":"The \'gpt-5.5\' model is not supported when using Codex with a ChatGPT account.","error":{"type":"invalid_request_error","code":"model_not_found"}}',
),
).toBe(true);
expect(helper(404, "anything")).toBe(true);
expect(helper(500, "internal error")).toBe(false);
expect(helper(429, "rate limited")).toBe(false);
@@ -360,6 +410,17 @@ describe("Codex slug fallback chain", () => {
"gpt-5.6-sol",
"gpt-5.5",
]);
// OpenAI retired these from Codex for ChatGPT sign-in: 5.2 and 5.3-Codex
// on 2026-05-26, 5.4 and 5.4 mini on 2026-08-31. A retired rung costs
// every account that reaches it a refused request.
for (const retired of [
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex",
"gpt-5.2",
]) {
expect(__test.DEFAULT_SLUG_FALLBACK_CHAIN).not.toContain(retired);
}
});
it("DEFAULT_SLUG_FALLBACK_CHAIN holds only slugs every family gate recognises", () => {
@@ -45,16 +45,22 @@ interface SeenRequest {
* `always`: the thought fills the ceiling whatever the effort (gpt-oss, GLM).
* `answers-unreasoned`: the same, except asked with `none` it answers
* (Gemma 4 on Ollama).
* `answers-with-room`: ignores the effort and answers once the ceiling leaves
* it the `high` thinking room (GLM 5.3 behind a gateway, #1276).
*/
let mode: "always" | "answers-unreasoned" = "always";
let mode: "always" | "answers-unreasoned" | "answers-with-room" = "always";
let server: http.Server;
let origin: string;
let seen: SeenRequest[] = [];
const ANSWER = JSON.stringify({ summary: "Steady." });
function reply(effort: unknown) {
const answers = mode === "answers-unreasoned" && effort === "none";
function reply(effort: unknown, maxTokens: unknown) {
const answers =
(mode === "answers-unreasoned" && effort === "none") ||
(mode === "answers-with-room" &&
typeof maxTokens === "number" &&
maxTokens >= 12_000);
return {
id: "chatcmpl-1279",
object: "chat.completion",
@@ -87,7 +93,14 @@ beforeAll(async () => {
maxTokens: body.max_tokens ?? body.max_completion_tokens,
});
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(reply(body.reasoning_effort)));
res.end(
JSON.stringify(
reply(
body.reasoning_effort,
body.max_tokens ?? body.max_completion_tokens,
),
),
);
});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
@@ -210,3 +223,77 @@ describe.each([
});
},
);
/** A daily briefing call as the chain runner hands it to a client. */
function briefingParams(unattended: boolean): CompletionParams {
return {
...singleUserTurn({
system: "You write the daily briefing.",
user: "Snapshot: …",
maxTokens: 300,
timeoutMs: 30_000,
responseFormat: "json",
}),
reasoning: { effort: "medium", summaries: false },
...(unattended ? { unattended: true } : {}),
};
}
describe.each([
["the OpenAI-compatible gateway", gateway],
["the Local client", local],
] as const)(
"#1276 — %s, background work for a model that cannot be switched down",
(_, make) => {
it("gives the #1267 repeat the High room, and answers on the first call", async () => {
mode = "answers-with-room";
const result = await make().generateCompletion(briefingParams(true));
expect(result.content).toBe(ANSWER);
// Two requests, the call's budget: medium at its own room, then the
// repeat with `none` and the High room the reply showed it needs.
expect(seen).toEqual([
{ reasoningEffort: "medium", maxTokens: 4396 },
{ reasoningEffort: "none", maxTokens: 12_300 },
]);
// Every later background call starts with the High room.
seen = [];
await make().generateCompletion(briefingParams(true));
expect(seen).toEqual([{ reasoningEffort: "none", maxTokens: 12_300 }]);
});
it("gives a status card, which asks no level, the same room", async () => {
mode = "answers-with-room";
const result = await make().generateCompletion({
...statusParams(),
unattended: true,
});
expect(result.content).toBe(ANSWER);
expect(seen.map((s) => s.maxTokens)).toEqual([300, 12_300]);
});
it("keeps the High room once even `none` is learned useless, and stays at one request", async () => {
const err = (await make()
.generateCompletion(briefingParams(true))
.catch((e) => e)) as Record<string, unknown>;
expect(err.kind).toBe("reasoning_only");
expect(seen.map((s) => s.maxTokens)).toEqual([4396, 12_300]);
seen = [];
await make()
.generateCompletion(briefingParams(true))
.catch(() => {});
expect(seen).toEqual([{ reasoningEffort: "medium", maxTokens: 12_300 }]);
});
it("leaves a call a person is reading at the room of its level", async () => {
await make()
.generateCompletion(briefingParams(false))
.catch(() => {});
seen = [];
await make()
.generateCompletion(briefingParams(false))
.catch(() => {});
expect(seen).toEqual([{ reasoningEffort: "medium", maxTokens: 4396 }]);
});
},
);
@@ -272,3 +272,29 @@ describe("#1279: background work and a benched provider", () => {
expect((err as AllProvidersFailedError).tokensSpent).toBe(5400);
});
});
describe("#1276 — the runner marks background calls", () => {
class Recorder implements AIProvider {
readonly type = "openai-compatible" as const;
seen: CompletionParams[] = [];
async generateCompletion(p: CompletionParams): Promise<CompletionResult> {
this.seen.push(p);
return { content: "{}", tokensUsed: 1 } as CompletionResult;
}
}
it.each([
["job", true],
["coach", undefined],
] as const)("a %s walk carries unattended: %s", async (surface, expected) => {
const instance = new Recorder();
await walk(
surface,
`u-unattended-${surface}`,
"openai-compatible",
instance,
createInMemoryProviderHealthLedger(),
);
expect(instance.seen[0]?.unattended).toBe(expected);
});
});
@@ -0,0 +1,38 @@
/**
* The chat request bodies take every language the app ships.
*
* The Coach chat, the document chat and the fenced chat declared `locale` as
* the pair de/en, so a body naming any other app language (French, Korean,
* Brazilian Portuguese, …) was refused with 422 before the reply language was
* ever resolved, although `resolveServerLocale` and the prompts downstream
* carry every locale.
*/
import { describe, expect, it } from "vitest";
import { locales } from "@/lib/i18n/config";
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
import {
documentChatRequestSchema,
fencedChatRequestSchema,
} from "@/lib/validations/inbound-documents";
const SCHEMAS = {
coach: (locale: string) =>
coachChatRequestSchema.safeParse({ message: "Hello", locale }),
document: (locale: string) =>
documentChatRequestSchema.safeParse({ message: "Hello", locale }),
fenced: (locale: string) =>
fencedChatRequestSchema.safeParse({ message: "Hello", locale }),
};
describe.each(Object.entries(SCHEMAS))("%s chat request", (_name, parse) => {
it.each([...locales])("accepts %s", (locale) => {
const parsed = parse(locale);
expect(parsed.success).toBe(true);
expect(parsed.data?.locale).toBe(locale);
});
it("refuses a language the app does not ship", () => {
expect(parse("xx").success).toBe(false);
});
});
+7 -7
View File
@@ -7,6 +7,7 @@
* can pass messages through with minimal translation.
*/
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import { COACH_SCOPE_SOURCES } from "./scope-sources";
@@ -85,15 +86,14 @@ export const coachChatRequestSchema = z.object({
conversationId: z.string().min(1).max(64).optional(),
message: z.string().min(1).max(4000),
prefill: z.string().max(2000).optional(),
locale: z.enum(["en", "de"]).optional(),
locale: z.enum(locales).optional(),
scope: coachScopeSchema.optional(),
/**
* v1.16.6 — guided clarifying-questions flow: the pending question
* this message answers. The question bubble is client-side only
* (never persisted), so without this context the model would see a
* bare answer ("since 2019, with medication") and could not react
* to it. Rides the prompt as delimited user-provided context; the
* persisted user turn stays the answer alone.
* v1.16.6 — guided clarifying-questions flow: the pending question this
* message answers. The question bubble is client-side only (never persisted),
* so without this context the model would see a bare answer ("since 2019,
* with medication") and could not react to it. Rides the prompt as delimited
* user-provided context; the persisted user turn stays the answer alone.
*/
guidedQuestion: z.string().min(1).max(500).optional(),
/**
+5 -7
View File
@@ -27,11 +27,10 @@ import {
noteReasoningSeen,
refusesReasoningField,
} from "./reasoning/dialect-cache";
import { uncontrolledThinkingRoom } from "./reasoning/levels";
import {
askAgainUnreasoned,
knownReasoner,
reasonsUnasked,
uncontrolledCallThinkingRoom,
repeatUnreasoned,
} from "./reasoning/answerless";
import {
@@ -149,11 +148,10 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// `none` sent for Off or after the #1267 lesson must not leave it the
// bare answer budget to think in. A call without a level gets the `low`
// room once the model has shown it reasons unasked.
const thinkingTokens = params.reasoning
? uncontrolledThinkingRoom(params.reasoning.effort)
: reasonsUnasked(this.reasoningDialectKey())
? uncontrolledThinkingRoom("off")
: 0;
const thinkingTokens = uncontrolledCallThinkingRoom(
this.reasoningDialectKey(),
params,
);
const body = JSON.stringify({
model: this.config.model,
messages,
+5 -12
View File
@@ -21,15 +21,12 @@ import {
import {
askAgainUnreasoned,
knownReasoner,
reasonsUnasked,
uncontrolledCallThinkingRoom,
repeatUnreasoned,
} from "./reasoning/answerless";
import { RequestHops } from "./request-hops";
import { learnFromRefusal } from "./wire-refusal";
import {
REASONING_THINKING_BUDGET,
uncontrolledThinkingRoom,
} from "./reasoning/levels";
import { REASONING_THINKING_BUDGET } from "./reasoning/levels";
import {
answerOf,
isOpenRouterEndpoint,
@@ -573,19 +570,15 @@ export class OpenAIClient implements AIProvider {
* included, and a `none` sent for the person's Off or after the #1267
* lesson must not leave it the bare answer budget to think in. Without a
* level of its own, a call gets the `low` room once the model has shown it
* reasons unasked.
* reasons unasked, and background work the `high` room once the model has
* answered with reasoning alone (`unattendedThinkingRoom`).
*/
private thinkingRoom(
params: CompletionParams,
wire: OpenAIReasoningWire,
): number {
if (this.isGateway) {
if (params.reasoning) {
return uncontrolledThinkingRoom(params.reasoning.effort);
}
return reasonsUnasked(this.reasoningDialectKey())
? uncontrolledThinkingRoom("off")
: 0;
return uncontrolledCallThinkingRoom(this.reasoningDialectKey(), params);
}
if (wire.kind === "none" || wire.effort === "none") return 0;
const level = levelOfWireEffort(wire.effort);
+8 -3
View File
@@ -743,14 +743,17 @@ export async function runStreamingRawCompletionWithFallback(
onDelta: (delta: string) => void;
},
): Promise<RunRawWithFallbackResult> {
// #1276 — a job walk is background work; stamped here, not per call site.
const params: CompletionParams =
args.surface === "job" ? { ...args.params, unattended: true } : args.params;
return runRawChain(
args,
// Prefer the provider's streaming wire; fall back to the buffered call for
// providers (cloud clients today) that do not implement streaming.
(candidate) =>
candidate.instance.generateCompletionStream
? candidate.instance.generateCompletionStream(args.params, args.onDelta)
: candidate.instance.generateCompletion(args.params),
? candidate.instance.generateCompletionStream(params, args.onDelta)
: candidate.instance.generateCompletion(params),
(candidate) => ({
ai_chain_streamed: Boolean(candidate.instance.generateCompletionStream),
}),
@@ -789,7 +792,9 @@ export interface RunRawWithFallbackResult {
export async function runRawCompletionWithFallback(
args: ChainWalkArgs,
): Promise<RunRawWithFallbackResult> {
const params: CompletionParams =
args.surface === "job" ? { ...args.params, unattended: true } : args.params;
return runRawChain(args, (candidate) =>
candidate.instance.generateCompletion(args.params),
candidate.instance.generateCompletion(params),
);
}
+48
View File
@@ -36,6 +36,11 @@ import {
rememberReasoningDialect,
type ReasoningDialectKey,
} from "./dialect-cache";
import {
REASONING_THINKING_BUDGET,
uncontrolledThinkingRoom,
type ReasoningLevel,
} from "./levels";
/**
* What the client does next with an answerless reply.
@@ -137,6 +142,49 @@ export function reasonsUnasked(key: ReasoningDialectKey): boolean {
);
}
/**
* #1276 — the thinking room of an unattended call (`CompletionParams.unattended`)
* to a model that has answered with reasoning alone (`reasonsUnasked`): at
* least the `high` room the Coach gets on High. Background work asks for
* `medium` at most, and a model that cannot be switched down (GLM behind
* vLLM) spends all of it thinking and answers nothing, the daily briefing's
* `reasoning_only`. The room applies from the #1267 repeat with `none` on:
* one call may send two requests (`request-hops.ts`), and a once-a-day job
* meets an expired lesson every morning, so the room has to ride on the
* repeat or the first job of the day never gets it. A ceiling, not a charge:
* a model that does stop at `none` never touches it, one that does not
* thinks as long as it does either way and reaches the answer. Every call a
* person is reading keeps `room`.
*/
export function unattendedThinkingRoom(
key: ReasoningDialectKey,
room: number,
unattended: boolean | undefined,
): number {
return unattended && reasonsUnasked(key)
? Math.max(room, REASONING_THINKING_BUDGET.high)
: room;
}
/**
* #1276 — the thinking room of one call on a wire whose reasoning switch the
* client cannot verify (a gateway, a local server): by the call's level, not
* by what the wire says, the `low` room for a call without a level once the
* model has shown it reasons unasked, and the `high` room for background work
* once it has answered with reasoning alone (`unattendedThinkingRoom`).
*/
export function uncontrolledCallThinkingRoom(
key: ReasoningDialectKey,
params: { reasoning?: { effort: ReasoningLevel }; unattended?: boolean },
): number {
const room = params.reasoning
? uncontrolledThinkingRoom(params.reasoning.effort)
: reasonsUnasked(key)
? uncontrolledThinkingRoom("off")
: 0;
return unattendedThinkingRoom(key, room, params.unattended);
}
/**
* #1276 — true when the model has shown it reasons, for a request that did
* (`asked`) or did not ask it to: a lesson that says it reasons unasked, or a
+8
View File
@@ -383,6 +383,14 @@ export interface CompletionParams {
* client sends exactly what it sent before the field existed.
*/
reasoning?: { effort: ReasoningLevel; summaries: boolean };
/**
* #1276 — no person reads this reply as it streams: the call runs as
* background work. Set by the chain runner for every `"job"` walk, never by
* a call site. The gateway and local clients give such a call the `high`
* thinking room once its model has answered with reasoning alone
* (`unattendedThinkingRoom`); nothing else reads it.
*/
unattended?: boolean;
/**
* v1.41.2 — a stable key for calls that resend the same prompt prefix (the
* rounds of one Coach turn, the turns of one conversation). Providers that
+2
View File
@@ -316,6 +316,8 @@ export async function computeRangeDelta(
userId,
type,
windowDays * 2,
undefined,
new Date(now),
);
if (!resolved) {
const empty: WindowAggregate = {
+311
View File
@@ -0,0 +1,311 @@
/**
* Confirming at the identity provider (#1296): which accounts are offered it,
* what the callback accepts as fresh, whose identity counts, and what the
* sealed state must carry.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
findUser: vi.fn<(args: unknown) => Promise<unknown>>(),
updateSession: vi.fn<(args: unknown) => Promise<unknown>>(),
countKeys: vi.fn<(args: unknown) => Promise<number>>(),
countPasskeys: vi.fn<(args: unknown) => Promise<number>>(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: db.findUser },
session: { update: db.updateSession },
webauthnMfaCredential: { count: db.countKeys },
passkey: { count: db.countPasskeys },
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
refundRateLimit: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((s: string) => s),
encrypt: vi.fn((s: string) => s),
}));
import { auditLog } from "@/lib/auth/audit";
import { refundRateLimit } from "@/lib/rate-limit";
import {
isFirstFactorOnlyProof,
recentProofMethods,
} from "@/lib/auth/second-factor";
import {
isFreshAuthTime,
isLinkedIdentity,
isReproofSession,
recordOidcReproof,
reproofReturnUrl,
} from "@/lib/auth/oidc-reproof";
import { readStoredOidcState } from "@/lib/auth/oidc-callback/state";
import { RECENT_PROOF_MAX_AGE_MS } from "@/lib/auth/existing-factor-proof";
const ISSUER = "https://idp.example.test";
const OIDC_ENV = {
OIDC_ISSUER_URL: ISSUER,
OIDC_CLIENT_ID: "client",
OIDC_CLIENT_SECRET: "secret",
};
function accountRow(overrides: Record<string, unknown> = {}) {
return {
passwordHash: null,
oidcIssuer: ISSUER,
oidcSub: "sub-1",
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
Object.assign(process.env, OIDC_ENV);
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.test";
db.countKeys.mockResolvedValue(0);
db.countPasskeys.mockResolvedValue(0);
});
afterEach(() => {
for (const key of Object.keys(OIDC_ENV)) delete process.env[key];
});
describe("recentProofMethods offers the identity provider", () => {
const user = { id: "u1", totpConfirmedAt: null };
it("to an account without a second factor whose identity is linked", async () => {
db.findUser.mockResolvedValue(accountRow());
expect(await recentProofMethods(user, false)).toEqual(["oidc"]);
});
it("beside the password and passkey the account also holds", async () => {
db.countPasskeys.mockResolvedValue(1);
db.findUser.mockResolvedValue(accountRow({ passwordHash: "hash" }));
expect(await recentProofMethods(user, false)).toEqual([
"password",
"oidc",
"passkey",
]);
});
it("not to an account without a linked identity", async () => {
db.findUser.mockResolvedValue(
accountRow({ oidcIssuer: null, oidcSub: null }),
);
expect(await recentProofMethods(user, false)).toEqual([]);
});
it("not when the instance has no provider configured", async () => {
delete process.env.OIDC_CLIENT_SECRET;
db.findUser.mockResolvedValue(accountRow());
expect(await recentProofMethods(user, false)).toEqual([]);
});
it("not to an account with a second factor, where it could not clear the gate", async () => {
db.countKeys.mockResolvedValue(1);
db.findUser.mockResolvedValue(accountRow());
expect(await recentProofMethods(user, true)).toEqual(["webauthn"]);
});
it("and never counts it as a second factor", () => {
expect(isFirstFactorOnlyProof("oidc")).toBe(true);
expect(isFirstFactorOnlyProof("password")).toBe(true);
expect(isFirstFactorOnlyProof("passkey")).toBe(false);
});
});
describe("isFreshAuthTime", () => {
const now = 1_800_000_000_000;
const seconds = (ms: number) => Math.floor(ms / 1000);
it("accepts an authentication inside the five-minute window", () => {
expect(isFreshAuthTime(seconds(now - 10_000), now)).toBe(true);
expect(isFreshAuthTime(seconds(now - 300_000), now)).toBe(true);
});
it("refuses one older than the window", () => {
expect(isFreshAuthTime(seconds(now - 301_000), now)).toBe(false);
expect(isFreshAuthTime(seconds(now - 3_600_000), now)).toBe(false);
});
it("refuses a token that does not say when", () => {
expect(isFreshAuthTime(null, now)).toBe(false);
});
it("tolerates a provider clock a minute ahead, and no more", () => {
expect(isFreshAuthTime(seconds(now + 30_000), now)).toBe(true);
expect(isFreshAuthTime(seconds(now + 120_000), now)).toBe(false);
});
});
describe("isLinkedIdentity", () => {
it("needs both the issuer and the subject to match", () => {
const account = { oidcIssuer: ISSUER, oidcSub: "sub-1" };
expect(isLinkedIdentity(account, ISSUER, "sub-1")).toBe(true);
expect(isLinkedIdentity(account, ISSUER, "sub-2")).toBe(false);
expect(isLinkedIdentity(account, "https://other.test", "sub-1")).toBe(
false,
);
expect(
isLinkedIdentity({ oidcIssuer: null, oidcSub: null }, ISSUER, "sub-1"),
).toBe(false);
});
});
describe("isReproofSession", () => {
const bound = { reproofUserId: "u1", reproofSessionId: "s1" };
it("holds only for the session and account the start bound", () => {
expect(
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u1" } }),
).toBe(true);
expect(
isReproofSession(bound, { session: { id: "s2" }, user: { id: "u1" } }),
).toBe(false);
expect(
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u2" } }),
).toBe(false);
expect(isReproofSession(bound, null)).toBe(false);
expect(
isReproofSession({}, { session: { id: "s1" }, user: { id: "u1" } }),
).toBe(false);
});
});
describe("recordOidcReproof", () => {
const now = 1_800_000_000_000;
const args = {
userId: "u1",
sessionId: "s1",
issuer: ISSUER,
ipAddress: "203.0.113.5",
nowMs: now,
};
it("stamps reproofAt only, refunds the attempt and audits the success", async () => {
db.findUser.mockResolvedValue(accountRow());
const outcome = await recordOidcReproof({
...args,
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 5 },
});
expect(outcome).toEqual({ ok: true });
// Stamped at the authentication, not at the callback.
expect(db.updateSession).toHaveBeenCalledWith({
where: { id: "s1" },
data: { reproofAt: new Date((Math.floor(now / 1000) - 5) * 1000) },
});
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:u1");
expect(auditLog).toHaveBeenCalledWith(
"auth.reproof.succeeded",
expect.objectContaining({ details: { method: "oidc" } }),
);
});
it("runs the window from the authentication, so a token near its end buys no extra minutes", async () => {
db.findUser.mockResolvedValue(accountRow());
const authTime = Math.floor(now / 1000) - 4 * 60;
await recordOidcReproof({ ...args, identity: { sub: "sub-1", authTime } });
const [call] = db.updateSession.mock.calls[0] as [
{ data: { reproofAt: Date } },
];
const stamped = call.data.reproofAt;
expect(stamped.getTime()).toBe(authTime * 1000);
// Two minutes after the callback the proof is six minutes old: past the
// window every recent-proof gate applies.
expect(now + 2 * 60_000 - stamped.getTime()).toBeGreaterThan(
RECENT_PROOF_MAX_AGE_MS,
);
});
it("never stamps a proof in the future for a provider clock a little ahead", async () => {
db.findUser.mockResolvedValue(accountRow());
await recordOidcReproof({
...args,
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) + 30 },
});
expect(db.updateSession).toHaveBeenCalledWith({
where: { id: "s1" },
data: { reproofAt: new Date(now) },
});
});
it("refuses a stale auth_time without stamping", async () => {
db.findUser.mockResolvedValue(accountRow());
const outcome = await recordOidcReproof({
...args,
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 3600 },
});
expect(outcome).toEqual({ ok: false, reason: "stale_auth_time" });
expect(db.updateSession).not.toHaveBeenCalled();
expect(auditLog).toHaveBeenCalledWith(
"auth.mfa.failed",
expect.objectContaining({
details: {
stage: "oidc_reproof",
method: "oidc",
reason: "stale_auth_time",
},
}),
);
});
it("refuses a different subject without stamping", async () => {
db.findUser.mockResolvedValue(accountRow());
const outcome = await recordOidcReproof({
...args,
identity: { sub: "someone-else", authTime: Math.floor(now / 1000) },
});
expect(outcome).toEqual({ ok: false, reason: "identity_mismatch" });
expect(db.updateSession).not.toHaveBeenCalled();
});
});
describe("the re-proof return and state", () => {
it("returns to the page it left with the outcome attached", () => {
const url = reproofReturnUrl("/settings/api?tab=mcp", "done");
expect(url.origin).toBe("https://healthlog.example.test");
expect(url.pathname).toBe("/settings/api");
expect(url.searchParams.get("tab")).toBe("mcp");
expect(url.searchParams.get("reproof")).toBe("done");
});
it("reads a reproof state only with both bindings and no other intent's fields", () => {
const base = { state: "s", nonce: "n", codeVerifier: "v", next: "/" };
const ok = readStoredOidcState(
JSON.stringify({
...base,
intent: "reproof",
reproofUserId: "u1",
reproofSessionId: "s1",
}),
);
expect(ok?.intent).toBe("reproof");
expect(
readStoredOidcState(
JSON.stringify({ ...base, intent: "reproof", reproofUserId: "u1" }),
),
).toBeNull();
expect(
readStoredOidcState(
JSON.stringify({
...base,
intent: "reproof",
reproofUserId: "u1",
reproofSessionId: "s1",
native: true,
appCodeChallenge: "c",
}),
),
).toBeNull();
expect(
readStoredOidcState(JSON.stringify({ ...base, intent: "other" })),
).toBeNull();
});
});
+72 -17
View File
@@ -37,6 +37,15 @@ const ENV_KEYS = [
] as const;
const ORIGINAL_ENV: Record<string, string | undefined> = {};
/**
* A verification result carrying only what `verifyIdToken` reads: the claims
* and the header `jose` returns beside them.
*/
type VerifyResult = Awaited<ReturnType<typeof mockJwtVerify>>;
function verified(payload: Record<string, unknown>): VerifyResult {
return { payload, protectedHeader: { alg: "RS256" } } as VerifyResult;
}
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
@@ -270,6 +279,25 @@ describe("buildAuthorizationUrl", () => {
}),
);
expect(url.searchParams.get("prompt")).toBe("login");
expect(url.searchParams.has("max_age")).toBe(false);
});
it("asks for a fresh, timed authentication for a re-proof", () => {
configureEnv();
const url = new URL(
buildAuthorizationUrl({
metadata: METADATA_DOC,
config: getOidcConfig()!,
state: "state-1",
nonce: "nonce-1",
codeChallenge: "challenge-1",
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
reauthenticate: true,
}),
);
expect(url.searchParams.get("prompt")).toBe("login");
expect(url.searchParams.get("max_age")).toBe("0");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
});
@@ -332,16 +360,15 @@ describe("verifyIdToken", () => {
beforeEach(() => configureEnv());
it("returns the identity claims on success", async () => {
// Test payloads deliberately omit the full JWTVerifyResult shape.
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: {
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({
sub: "user-sub-1",
email: "person@example.com",
email_verified: true,
name: "Person",
nonce: "nonce-1",
},
} as never);
}),
);
const config = getOidcConfig()!;
const identity = await verifyIdToken({
metadata: METADATA_DOC,
@@ -354,13 +381,43 @@ describe("verifyIdToken", () => {
email: "person@example.com",
emailVerified: true,
name: "Person",
authTime: null,
});
});
it("returns auth_time when the provider states it, and null otherwise", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({
sub: "user-sub-1",
nonce: "nonce-1",
auth_time: 1_700_000_000,
}),
);
const config = getOidcConfig()!;
const stated = await verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
});
expect(stated.authTime).toBe(1_700_000_000);
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({ sub: "user-sub-1", nonce: "nonce-1", auth_time: "recent" }),
);
const unstated = await verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
});
expect(unstated.authTime).toBeNull();
});
it("rejects a nonce mismatch", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "wrong-nonce" },
} as never);
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({ sub: "user-sub-1", nonce: "wrong-nonce" }),
);
const config = getOidcConfig()!;
await expect(
verifyIdToken({
@@ -373,9 +430,7 @@ describe("verifyIdToken", () => {
});
it("rejects a token missing sub", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { nonce: "nonce-1" },
} as never);
vi.mocked(mockJwtVerify).mockResolvedValue(verified({ nonce: "nonce-1" }));
const config = getOidcConfig()!;
await expect(
verifyIdToken({
@@ -388,9 +443,9 @@ describe("verifyIdToken", () => {
});
it("verifies with a closed asymmetric algorithm allowlist and bounded clock tolerance", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "nonce-1" },
} as never);
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
);
const config = getOidcConfig()!;
await verifyIdToken({
metadata: METADATA_DOC,
@@ -411,9 +466,9 @@ describe("verifyIdToken", () => {
});
it("pins the JWKS fetch to the issuer's host with a bounded timeout", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "nonce-1" },
} as never);
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
);
const config = getOidcConfig()!;
await verifyIdToken({
metadata: METADATA_DOC,
+20 -5
View File
@@ -56,6 +56,21 @@ beforeEach(() => {
] as never);
});
describe("applyProfileUpdate locale", () => {
it.each(["fr", "pt-BR", "ko"])(
"stores %s, a language the app ships",
async (locale) => {
const result = await applyProfileUpdate(USER_ID, { locale });
expect(result.ok).toBe(true);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ locale }),
}),
);
},
);
});
describe("applyProfileUpdate timezone validation", () => {
it("accepts a valid IANA zone", async () => {
const result = await applyProfileUpdate(USER_ID, {
@@ -271,7 +286,7 @@ describe("applyProfileUpdate partial-field save", () => {
it("writes nothing and reports every rejected field when the whole body is invalid", async () => {
const result = await applyProfileUpdate(USER_ID, {
gender: "male",
locale: "fr",
locale: "xx",
});
expect(result.ok).toBe(false);
if (!result.ok) {
@@ -290,15 +305,15 @@ describe("applyProfileUpdate top-level error message", () => {
// sharpest regression check available for "does the raw Zod default
// ever reach the top-level string a person reads". Zod v4's default
// enum message is shaped like:
// Invalid option: expected one of "de"|"en"
// Invalid option: expected one of "de"|"en"|"fr"|…
const RAW_ZOD_ENUM_PATTERN = /expected one of/i;
it("never lets the raw Zod enum message reach the top-level `message`", async () => {
const result = await applyProfileUpdate(USER_ID, { locale: "fr" });
const result = await applyProfileUpdate(USER_ID, { locale: "xx" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).not.toMatch(RAW_ZOD_ENUM_PATTERN);
expect(result.message).not.toContain('"de"|"en"');
expect(result.message).not.toContain('"de"|"en"|"fr"');
// Relocated, not deleted: the same detail is still available for
// whoever needs it (the iOS `details.issues` contract), just not
// in the string a person reads first.
@@ -318,7 +333,7 @@ describe("applyProfileUpdate top-level error message", () => {
});
it("carries a stable errorCode so the web client can localize the sentence", async () => {
const result = await applyProfileUpdate(USER_ID, { locale: "fr" });
const result = await applyProfileUpdate(USER_ID, { locale: "xx" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errorCode).toBe("profile.update.nothingSaved");
+2 -1
View File
@@ -29,6 +29,7 @@ import {
import {
hasSecondFactorEnrolled,
recentProofMethods,
type RecentProofMethod,
} from "@/lib/auth/second-factor";
import { verifyPassword } from "@/lib/auth/password";
import { verifyAuthentication } from "@/lib/auth/passkey";
@@ -171,7 +172,7 @@ export async function recordReproofFailure(
userId: string,
ipAddress: string | null,
stage: string,
method: ExistingFactorProof["method"],
method: RecentProofMethod,
reason: string,
): Promise<void> {
await auditLog("auth.mfa.failed", {
+4 -2
View File
@@ -3,6 +3,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { getSession } from "@/lib/auth/session";
import {
hasSecondFactorEnrolled,
isFirstFactorOnlyProof,
recentProofMethods,
} from "@/lib/auth/second-factor";
import { meterAccountWrite } from "@/lib/auth/write-meter";
@@ -80,9 +81,10 @@ export async function requireFreshMfa(
// does, so the browser can open the re-proof dialog instead of printing a
// sentence with nowhere to act on it. Only the possession proofs: a
// `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a
// password stamps `reproofAt` only and would leave this gate shut.
// password or a provider sign-in stamps `reproofAt` only and would leave
// this gate shut.
const methods = (await recentProofMethods(sessionData.user)).filter(
(method) => method !== "password",
(method) => !isFirstFactorOnlyProof(method),
);
annotate({ meta: { step_up_methods: methods.join(",") } });
throw new StepUpRequiredError(
+26 -4
View File
@@ -27,8 +27,17 @@ export interface StoredOidcState {
* names instead of signing in, and only when the cookie still carries this
* exact binding. Web only: never together with `native`.
*/
intent?: "claim";
intent?: "claim" | "reproof";
claimBinding?: string;
/**
* Set only for `intent: "reproof"` (`GET /api/auth/oidc/reproof`): the
* signed-in account and the session row the round trip confirms. The
* callback stamps nothing unless the browser still carries exactly this
* session and the provider names exactly this account's linked identity.
* Web only, like the claim.
*/
reproofUserId?: string;
reproofSessionId?: string;
}
function isStoredOidcState(value: unknown): value is StoredOidcState {
@@ -50,11 +59,24 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
if (typeof v.appCodeChallenge !== "string") return false;
}
if (v.intent !== undefined) {
if (v.intent !== "claim") return false;
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
if (v.native !== undefined) return false;
if (v.intent === "claim") {
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
return false;
}
} else if (v.intent === "reproof") {
if (
typeof v.reproofUserId !== "string" ||
v.reproofUserId.length === 0 ||
typeof v.reproofSessionId !== "string" ||
v.reproofSessionId.length === 0 ||
v.claimBinding !== undefined
) {
return false;
}
} else {
return false;
}
if (v.native !== undefined) return false;
}
return true;
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Confirming it is you through the identity provider.
*
* An account created by single sign-on holds no password, passkey or second
* factor, so the recent-proof gate (token minting, exports, share links, the
* admin data actions) had nothing to ask it for. This is the proof such an
* account can give: the browser goes back to the provider with
* `prompt=login` and `max_age=0`, and the callback counts the round trip only
* when the ID token says the provider authenticated the person inside the
* gate's own window and names the identity linked to the signed-in account.
*
* It records the proof exactly as a password re-proof does, `Session.reproofAt`
* and nothing else, so it never stands in for a second factor. It creates no
* session and switches none: the callback stamps the session row the start
* bound into the sealed state, and only while the browser still carries it.
*
* Start: `GET /api/auth/oidc/reproof?next=<path>`. Finish: the shared
* `GET /api/auth/oidc/callback` with `intent: "reproof"` in the state blob.
*/
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { oidcAppUrl, type OidcIdentity } from "@/lib/auth/oidc";
import {
RECENT_PROOF_MAX_AGE_MS,
recordReproofFailure,
refundReproof,
} from "@/lib/auth/existing-factor-proof";
/** The query parameter the browser comes back with, and its two values. */
export const OIDC_REPROOF_RESULT_PARAM = "reproof";
export type OidcReproofResult = "done" | "failed";
/** The stage name the shared re-proof bucket and the audit rows carry. */
export const OIDC_REPROOF_STAGE = "oidc_reproof";
/**
* How far in the future `auth_time` may sit and still count: the clock
* tolerance `verifyIdToken` already grants `iat` and `exp`, for an identity
* provider on another box whose clock runs a little ahead.
*/
const AUTH_TIME_FUTURE_TOLERANCE_MS = 60_000;
/**
* Did the provider authenticate the person inside the gate's window (the five
* minutes every recent-proof surface uses)? A token
* without `auth_time` is refused: with `max_age` in the request the provider
* owes the claim (OpenID Connect Core 2), and without it nothing says the
* person signed in now rather than an hour ago.
*/
export function isFreshAuthTime(
authTime: number | null,
nowMs: number,
maxAgeMs: number = RECENT_PROOF_MAX_AGE_MS,
): boolean {
if (authTime === null) return false;
const at = authTime * 1000;
return nowMs - at <= maxAgeMs && at - nowMs <= AUTH_TIME_FUTURE_TOLERANCE_MS;
}
/**
* Is the identity the provider named the one linked to this account? Both
* halves, compared exactly: the subject is only unique per issuer.
*/
export function isLinkedIdentity(
account: { oidcIssuer: string | null; oidcSub: string | null },
issuer: string,
sub: string,
): boolean {
return (
account.oidcIssuer !== null &&
account.oidcSub !== null &&
account.oidcIssuer === issuer &&
account.oidcSub === sub
);
}
/**
* Does the browser still carry the session the round trip was started from?
* Checked before the code is redeemed, so a mismatch spends nothing at the
* provider.
*/
export function isReproofSession(
bound: { reproofUserId?: string; reproofSessionId?: string },
live: { session: { id: string }; user: { id: string } } | null,
): boolean {
return (
live !== null &&
typeof bound.reproofSessionId === "string" &&
typeof bound.reproofUserId === "string" &&
live.session.id === bound.reproofSessionId &&
live.user.id === bound.reproofUserId
);
}
/** Where the browser lands: the page it left, with the outcome attached. */
export function reproofReturnUrl(next: string, result: OidcReproofResult): URL {
const url = oidcAppUrl(next);
url.searchParams.set(OIDC_REPROOF_RESULT_PARAM, result);
return url;
}
export type OidcReproofOutcome =
{ ok: true } | { ok: false; reason: "identity_mismatch" | "stale_auth_time" };
/**
* Check the verified ID token against the account and stamp the session.
* Every refusal is audited like a failed password re-proof; a success gives
* back the attempt the start charged to the shared bucket.
*/
export async function recordOidcReproof(args: {
userId: string;
sessionId: string;
issuer: string;
identity: Pick<OidcIdentity, "sub" | "authTime">;
ipAddress: string | null;
nowMs?: number;
}): Promise<OidcReproofOutcome> {
const { userId, sessionId, issuer, identity, ipAddress } = args;
const nowMs = args.nowMs ?? Date.now();
const account = await prisma.user.findUnique({
where: { id: userId },
select: { oidcIssuer: true, oidcSub: true },
});
let refusal: "identity_mismatch" | "stale_auth_time" | null = null;
if (!account || !isLinkedIdentity(account, issuer, identity.sub)) {
refusal = "identity_mismatch";
} else if (!isFreshAuthTime(identity.authTime, nowMs)) {
refusal = "stale_auth_time";
}
if (refusal) {
await recordReproofFailure(
userId,
ipAddress,
OIDC_REPROOF_STAGE,
"oidc",
refusal,
);
return { ok: false, reason: refusal };
}
// The proof is as old as the authentication, not the callback: the gate's
// window then runs from the moment the person signed in at the provider,
// so a token near the end of its window buys no extra minutes. A provider
// clock a little ahead is clamped to now, never stamped into the future.
// (A token without `auth_time` was refused above.)
const authenticatedAtMs =
identity.authTime === null
? nowMs
: Math.min(identity.authTime * 1000, nowMs);
await prisma.session.update({
where: { id: sessionId },
data: { reproofAt: new Date(authenticatedAtMs) },
});
await refundReproof(userId);
await auditLog("auth.reproof.succeeded", {
userId,
ipAddress,
details: { method: "oidc" },
});
annotate({
action: { name: "auth.reproof.succeeded" },
meta: { method: "oidc" },
});
return { ok: true };
}
+20 -1
View File
@@ -244,6 +244,13 @@ export function buildAuthorizationUrl(params: {
* keyboard now, not a session someone else left open in this browser.
*/
forceLogin?: boolean;
/**
* Ask the provider to authenticate afresh AND to say when it did: `prompt=login`
* plus `max_age=0` (OpenID Connect Core 3.1.2.1). With `max_age` in the
* request the ID token must carry `auth_time`, which is what the re-proof
* leg checks before it counts the round trip as a proof.
*/
reauthenticate?: boolean;
}): string {
const url = new URL(params.metadata.authorization_endpoint);
url.search = new URLSearchParams({
@@ -255,7 +262,8 @@ export function buildAuthorizationUrl(params: {
nonce: params.nonce,
code_challenge: params.codeChallenge,
code_challenge_method: "S256",
...(params.forceLogin ? { prompt: "login" } : {}),
...(params.forceLogin || params.reauthenticate ? { prompt: "login" } : {}),
...(params.reauthenticate ? { max_age: "0" } : {}),
}).toString();
return url.toString();
}
@@ -304,6 +312,12 @@ export interface OidcIdentity {
email: string | null;
emailVerified: boolean | undefined;
name: string | null;
/**
* When the provider last authenticated the person (`auth_time`, seconds
* since the epoch), or null when the token does not say. Only the re-proof
* leg reads it, and it refuses a null.
*/
authTime: number | null;
}
/**
@@ -350,6 +364,11 @@ export async function verifyIdToken(params: {
? payload.email_verified
: undefined,
name: typeof payload.name === "string" ? payload.name : null,
authTime:
typeof payload.auth_time === "number" &&
Number.isFinite(payload.auth_time)
? payload.auth_time
: null,
};
}
+5 -2
View File
@@ -17,6 +17,7 @@ import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response";
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import {
REPROOF_FAILED_CODE,
REPROOF_REQUIRED_CODE,
@@ -26,9 +27,11 @@ import {
const extendedProfileSchema = profileSchema.extend({
// Trimmed first; "" (or only spaces) clears the name to null below.
displayName: optionalDisplayNameSchema.nullable().optional(),
// Every language the app ships: the stored language is what background
// work (the Coach nudge, the notification texts) writes in.
locale: z
.enum(["de", "en"], {
error: "Locale must be de or en.",
.enum(locales, {
error: "Locale must be one of the languages the app ships.",
})
.nullable()
.optional(),
+28 -7
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { isOidcConfigured } from "@/lib/auth/oidc";
/**
* Does this account have a SECOND FACTOR at all?
@@ -40,11 +41,25 @@ export async function hasSecondFactorEnrolled(user: {
}
/**
* A proof the recent-proof gate can ask for. `password` and `passkey` on an
* account without a second factor; `totp`, `webauthn` and `passkey` on one
* with a second factor, where a password alone has never been enough.
* A proof the recent-proof gate can ask for. `password`, `oidc` and `passkey`
* on an account without a second factor; `totp`, `webauthn` and `passkey` on
* one with a second factor, where a password alone has never been enough.
*
* `oidc` is a fresh sign-in at the account's linked identity provider
* (`GET /api/auth/oidc/reproof`), not a body proof: it stamps
* `Session.reproofAt` exactly as a password does, and like a password it never
* stands in for a second factor.
*/
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
export type RecentProofMethod =
"password" | "oidc" | "totp" | "webauthn" | "passkey";
/**
* The proofs that stamp only `Session.reproofAt` and so never clear a gate that
* asks for a second factor (`requireFreshMfa`).
*/
export function isFirstFactorOnlyProof(method: RecentProofMethod): boolean {
return method === "password" || method === "oidc";
}
/**
* The proofs that would satisfy `assertRecentCookieProof` for this
@@ -63,15 +78,21 @@ export async function recentProofMethods(
prisma.passkey.count({ where: { userId: user.id } }),
prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
select: { passwordHash: true, oidcIssuer: true, oidcSub: true },
}),
]);
const methods: RecentProofMethod[] = [];
if (hasSecond) {
if (user.totpConfirmedAt) methods.push("totp");
if (keys > 0) methods.push("webauthn");
} else if (credential?.passwordHash) {
methods.push("password");
} else {
if (credential?.passwordHash) methods.push("password");
// A linked identity is only a way to confirm while the instance still
// talks to a provider: with the provider group unset the round trip has
// nowhere to go.
if (credential?.oidcIssuer && credential.oidcSub && isOidcConfigured()) {
methods.push("oidc");
}
}
if (passkeys > 0) methods.push("passkey");
return methods;
+2
View File
@@ -358,6 +358,8 @@ export const INSTANCE_SCOPED: Readonly<Record<string, string>> = {
"the monthly restore drill's last verdict for the instance's off-host bucket; says nothing about any account",
RecoveryReadinessNotice:
"per backup signal, what the admins were last told; a fact about the instance's backups, not about any account",
SystemJobAlertNotice:
"per background queue, what the admins were last told about its failures; a fact about the instance's jobs, not about any account",
RecoveryKitConfirmation:
"the instance's recovery kit confirmation (key ids and fingerprints); the confirming admin is a plain id, not a relation",
DatabaseBackupRun:
+188 -1
View File
@@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
labResult: { create: vi.fn() },
illnessEpisode: { create: vi.fn() },
},
}));
@@ -27,8 +28,16 @@ import { commitApprovedFact, FactCommitError } from "@/lib/documents/commit";
import { prisma } from "@/lib/db";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { decryptFactData } from "@/lib/documents/store";
import type { ExtractedFact } from "@/generated/prisma/client";
import type {
ExtractedFact,
IllnessEpisode,
LabResult,
} from "@/generated/prisma/client";
import { userDayKey } from "@/lib/tz/format";
import type {
ConditionFactData,
ObservationFactData,
} from "@/lib/validations/inbound-documents";
function observationFact(): ExtractedFact {
return {
@@ -213,3 +222,181 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
).resolves.toEqual({ recordType: "labResult", recordId: "lr-2" });
});
});
describe("commitApprovedFact — OBSERVATION date when the value states none", () => {
// A lab report that prints the collection date once in its header carries
// no date beside each value. Mutation check: drop the document day from
// `takenAt` → the report-date, filing-date and impossible-date cases file
// the reading under today.
function undated(effectiveDate: string | null = null) {
const data: ObservationFactData = {
label: "Ferritin",
code: null,
codeSystem: null,
value: 80,
valueText: null,
unit: "ng/mL",
referenceLow: null,
referenceHigh: null,
effectiveDate,
};
vi.mocked(decryptFactData).mockReturnValue(data);
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
id: "bm-3",
name: "Ferritin",
unit: "ng/mL",
lowerBound: null,
upperBound: null,
panel: null,
analyteKey: null,
});
vi.mocked(prisma.labResult.create).mockResolvedValue({
id: "lr-3",
} as LabResult);
}
/** The dates as the confirm route reads them off the stored document. */
function dates(reportDate: string | null, documentDate: string | null) {
return {
// An older report date sits at UTC midnight, newer ones at noon UTC.
reportDate: reportDate ? new Date(`${reportDate}T00:00:00.000Z`) : null,
documentDate: documentDate
? new Date(`${documentDate}T12:00:00.000Z`)
: null,
};
}
function storedTakenAt(): Date {
return vi.mocked(prisma.labResult.create).mock.calls[0]![0].data
.takenAt as Date;
}
it("keeps the value's own date", async () => {
undated("2021-03-04");
await commitApprovedFact(
"user-1",
observationFact(),
{},
dates("2021-03-01", "2026-10-20"),
);
expect(storedTakenAt().toISOString()).toBe("2021-03-04T12:00:00.000Z");
});
it("takes the report date the document states over its filing date", async () => {
undated();
await commitApprovedFact(
"user-1",
observationFact(),
{},
dates("2021-03-01", "2026-10-20"),
);
expect(storedTakenAt().toISOString()).toBe("2021-03-01T12:00:00.000Z");
});
it("takes the filing date when the document states no report date", async () => {
undated();
await commitApprovedFact(
"user-1",
observationFact(),
{},
dates(null, "2021-02-15"),
);
expect(storedTakenAt().toISOString()).toBe("2021-02-15T12:00:00.000Z");
});
it.each([
["not a date", "March 2021"],
["a day the month does not have", "2021-02-30"],
["a month that does not exist", "2021-13-01"],
])(
"reads a value whose own date is %s as undated",
async (_label, effectiveDate) => {
undated(effectiveDate);
await commitApprovedFact(
"user-1",
observationFact(),
{},
dates("2021-03-01", null),
);
// Not 2 March for 30 February, and no Invalid Date for month 13.
expect(storedTakenAt().toISOString()).toBe("2021-03-01T12:00:00.000Z");
},
);
it("files the reading under now when neither the value nor the document has a date", async () => {
undated();
const before = Date.now();
await commitApprovedFact(
"user-1",
observationFact(),
{},
dates(null, null),
);
const after = Date.now();
const takenAt = storedTakenAt().getTime();
expect(takenAt).toBeGreaterThanOrEqual(before);
expect(takenAt).toBeLessThanOrEqual(after);
});
});
describe("commitApprovedFact — CONDITION onset", () => {
// The onset goes through the same stated-day check as a lab value's date.
function condition(onsetDate: string | null) {
const data: ConditionFactData = {
label: "Iron deficiency",
code: null,
codeSystem: null,
clinicalStatus: null,
verificationStatus: null,
onsetDate,
};
vi.mocked(decryptFactData).mockReturnValue(data);
vi.mocked(prisma.illnessEpisode.create).mockResolvedValue({
id: "ep-1",
} as IllnessEpisode);
}
function conditionFact(): ExtractedFact {
return {
id: "fact-2",
factType: "CONDITION",
dataEncrypted: new Uint8Array(),
} as unknown as ExtractedFact;
}
function storedOnset(): Date {
return vi.mocked(prisma.illnessEpisode.create).mock.calls[0]![0].data
.onsetAt as Date;
}
it("keeps a stated onset day", async () => {
condition("2021-03-04");
await commitApprovedFact("user-1", conditionFact());
expect(storedOnset().toISOString()).toBe("2021-03-04T12:00:00.000Z");
});
it("files an onset that is not a real day under now, not an invalid date", async () => {
// 2021-13-01 used to become an invalid date that failed the whole confirm.
condition("2021-13-01");
const before = Date.now();
await commitApprovedFact("user-1", conditionFact());
const after = Date.now();
const onset = storedOnset().getTime();
expect(onset).toBeGreaterThanOrEqual(before);
expect(onset).toBeLessThanOrEqual(after);
});
});
+45 -7
View File
@@ -44,7 +44,11 @@ import type {
MedicationStatementFactData,
ObservationFactData,
} from "@/lib/validations/inbound-documents";
import { dateOnlyAtNoonUtc, isDateOnlyKey } from "@/lib/tz/date-only";
import {
dateOnlyAtNoonUtc,
dateOnlyKey,
isCalendarDateKey,
} from "@/lib/tz/date-only";
/** A per-fact commit failure the caller maps to a per-fact 422 entry. */
export class FactCommitError extends Error {
@@ -61,14 +65,41 @@ export interface CommittedRecordRef {
recordId: string;
}
/** A stated day, or null when it is not a real calendar date. */
export function statedDay(date: string | null): string | null {
return date && isCalendarDateKey(date) ? date : null;
}
/**
* A stated YYYY-MM-DD as the instant a date-only value is stored at (noon
* UTC), or now when the document states none. UTC midnight read back as the
* UTC), else the fallback day, else now. UTC midnight read back as the
* previous day anywhere west of UTC.
*/
function statedDateOrNow(date: string | null): Date {
if (date && isDateOnlyKey(date)) return dateOnlyAtNoonUtc(date);
return new Date();
function statedDateOrNow(
date: string | null,
fallback: string | null = null,
): Date {
const day = statedDay(date) ?? fallback;
return day ? dateOnlyAtNoonUtc(day) : new Date();
}
/** The two dates a stored document carries, as the confirm route reads them. */
export interface FactDocumentDates {
reportDate: Date | null;
documentDate: Date | null;
}
/**
* The day a value from this document took place when it states none of its
* own: the report date the model read off the document, else the document's
* filing date, the same order the day view and the timeline place a document
* by. A lab report that prints the collection date once in its header carries
* no date beside each value, and every reading used to be filed under the day
* it was confirmed.
*/
export function documentDay(dates: FactDocumentDates | null): string | null {
const day = dates?.reportDate ?? dates?.documentDate ?? null;
return day ? dateOnlyKey(day) : null;
}
/**
@@ -84,6 +115,7 @@ async function commitObservation(
userId: string,
data: ObservationFactData,
choice: ObservationChoice = {},
fallbackDay: string | null = null,
): Promise<CommittedRecordRef> {
const isQualitative = typeof data.value !== "number";
// A numeric reading needs a unit so the catalog has something to mint with —
@@ -228,7 +260,7 @@ async function commitObservation(
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
sourceReferenceText: isQualitative ? null : sourceText,
takenAt: statedDateOrNow(data.effectiveDate),
takenAt: statedDateOrNow(data.effectiveDate, fallbackDay),
source: "DOCUMENT",
noteEncrypted: null,
},
@@ -317,12 +349,18 @@ export async function commitApprovedFact(
userId: string,
fact: ExtractedFact,
choice: ObservationChoice = {},
documentDates: FactDocumentDates | null = null,
): Promise<CommittedRecordRef> {
// Decrypt the staged payload at confirm time to write it into the normal
// structured store. Fail-closed: a bad key id throws and aborts the commit.
const data = decryptFactData(fact.dataEncrypted);
if (fact.factType === "OBSERVATION") {
return commitObservation(userId, data as ObservationFactData, choice);
return commitObservation(
userId,
data as ObservationFactData,
choice,
documentDay(documentDates),
);
}
if (fact.factType === "CONDITION") {
return commitCondition(userId, data as ConditionFactData);
+1 -1
View File
@@ -89,7 +89,7 @@ Scope: `googlehealth.health_metrics_and_measurements.readonly`.
| `daily-heart-rate-variability` | `dailyHeartRateVariability.averageHeartRateVariabilityMilliseconds` | `HRV_RMSSD` | ms | `hrv` | daily | Google documents the daily average as RMSSD, so it lands in `HRV_RMSSD`, apart from the SDNN `HEART_RATE_VARIABILITY`. |
| `daily-resting-heart-rate` | `dailyRestingHeartRate.beatsPerMinute` (int64 string) | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
| `daily-respiratory-rate` | `dailyRespiratoryRate.breathsPerMinute` (number) | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | `respiratory-rate` does NOT exist in the catalogue. Schema is `{date, breathsPerMinute}` — the earlier `dailyRespiratoryRateBpm` leaf never existed. |
| `heart-rate` | `heartRate.beatsPerMinute` (int64 string) | `PULSE` | bpm | `hr` | sample | Intraday spot HR; per-minute volume — consider the 14-day-max rollup if PULSE should stay coarse. |
| `heart-rate` | `heartRate.beatsPerMinute` (int64 string) | `PULSE` | bpm | `hr` | sample | Intraday spot HR, one row per sample; older than 90 days it folds to hourly means (`dense-intraday-sources.ts`). |
| `blood-glucose` | `bloodGlucose.bloodGlucoseMilligramsPerDeciliter` (number) | `BLOOD_GLUCOSE` | mg/dL | `glucose` | sample | Already in the canonical mg/dL storage unit. Meal/timing/specimen enums not stored. |
| `core-body-temperature` | `coreBodyTemperature.temperatureCelsius` (number) | `BODY_TEMPERATURE` | celsius | `core_temp` | sample | Core slot — distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE. |
| `daily-sleep-temperature-derivations` | `dailySleepTemperatureDerivations.nightlyTemperatureCelsius` (number) | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | ABSOLUTE nightly skin temperature (mean over the night's samples) — the schema refutes the earlier "deviation-only" premise. Baseline/stddev derivations not stored. |
+18 -18
View File
@@ -36,6 +36,7 @@ import {
type MeasurementReconciliationVerdict,
} from "@/lib/measurements/reconcile-external-measurement";
import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import { withoutFoldedSamples } from "@/lib/measurements/folded-window";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { recordSyncFailure, type FailureKind } from "@/lib/integrations/status";
import { collapseToTypeDayKeys } from "@/lib/rollups/measurement-rollups";
@@ -1128,7 +1129,7 @@ export async function upsertGoogleHealthMeasurements(
// before they can become rows. A refused reading is dropped and tallied on
// the ambient event, never clamped and never written for a later reader to
// discover.
const { kept: readings, dropped: naturalKeyTwins } = collapseNaturalKeyTwins(
const { kept: collapsed, dropped: naturalKeyTwins } = collapseNaturalKeyTwins(
dropImplausibleMeasurements("googleHealth", incoming, (r) => ({
type: r.type,
value: r.value,
@@ -1137,14 +1138,18 @@ export async function upsertGoogleHealthMeasurements(
if (naturalKeyTwins > 0) {
annotate({ meta: { "googleHealth.naturalKeyTwins": naturalKeyTwins } });
}
// A sample of an hour the dense fold already took is not written again.
const { kept: readings } = await withoutFoldedSamples(
prisma,
userId,
"GOOGLE_HEALTH",
collapsed,
);
if (readings.length === 0) return { imported: 0, touched: [], inserted: [] };
// Probe EVERY existing row (live AND tombstoned) for the batch's externalIds
// in a single query. The full unique index guarantees at most one row per
// `(type, externalId)` key, and a tombstoned match must take the UPDATE
// branch (which clears `deletedAt`) — treating it as absent would plan an
// insert that `skipDuplicates` silently drops against the tombstone's key,
// permanently wedging the key (see TOMBSTONES RESURRECT above).
// in one query: a tombstoned match must take the UPDATE branch, or the
// insert is silently dropped against its key (see TOMBSTONES RESURRECT).
const externalIds = readings.map((r) => r.externalId);
interface ProbedRow {
id: string;
@@ -1162,10 +1167,8 @@ export async function upsertGoogleHealthMeasurements(
source: "GOOGLE_HEALTH",
externalId: { in: externalIds },
},
// The payload fields ride along so the update branch can skip a no-op
// overwrite: the 24 h overlap re-fetches every recent row hourly, and an
// unconditional write would bump `syncVersion` (iOS delta churn) on rows
// whose values did not change.
// The payload rides along so the update branch can skip a no-op
// overwrite (the hourly 24 h overlap would bump `syncVersion` for nothing).
select: {
id: true,
type: true,
@@ -1193,9 +1196,8 @@ export async function upsertGoogleHealthMeasurements(
]),
);
} catch (err) {
// A probe failure must not strand the whole batch; fall back to treating
// every row as fresh (`skipDuplicates` absorbs the collision on the full
// unique index — those rows simply retry on the next sync tick).
// A probe failure must not strand the batch: every row is treated as
// fresh, and `skipDuplicates` absorbs the collisions until the next tick.
getEvent()?.addWarning(`google-health: row probe failed: ${err}`);
}
@@ -1225,11 +1227,9 @@ export async function upsertGoogleHealthMeasurements(
const key = `${type} ${r.externalId}`;
const existingRow = rowByKey.get(key);
if (existingRow) {
// Skip the no-op overwrite: a LIVE row whose payload matches the
// re-fetched reading exactly gains nothing from a write, and the
// unconditional `syncVersion` bump churned the DB + every iOS delta pull
// hourly for the whole 24 h overlap window. A TOMBSTONED row always
// updates — the write is what resurrects it (`deletedAt: null`).
// Skip the no-op overwrite of a LIVE row whose payload matches exactly
// (it would churn `syncVersion` and every iOS delta pull). A TOMBSTONED
// row always updates: the write is what resurrects it.
const unchanged =
existingRow.deletedAt === null &&
existingRow.value === r.value &&
@@ -63,8 +63,11 @@ describe("first paint carries the resolved locale, not a default", () => {
/prev\.locale === locale \? \{ locale, messages: loaded \} : prev/,
);
// And the persistence effect keys on the locale alone, so one mount
// with one locale means one write.
expect(src).toMatch(/persistLocaleToServer\(locale\);\s*\}, \[locale\]\);/);
// And the provider writes nothing to the server itself: the mirror lives
// in `LocaleServerSyncBridge`, keyed on the locale and the signed-in
// account, so a signed-out mount sends nothing and one account with one
// locale means one write (its browser test pins both).
expect(src).toMatch(/export function persistLocaleToServer\(/);
expect(src).not.toMatch(/persistLocaleToServer\(locale/);
});
});
+12 -14
View File
@@ -125,16 +125,23 @@ function persistLocale(newLocale: Locale) {
// the server-set cookie the route emits in reply: Safari's ITP caps the
// script-written copy at 7 days, and a Set-Cookie is exempt.
//
// Once per tab session and per locale, not on every mount: every page load
// mounts the provider, and every authenticated write is charged to the
// Once per tab session, account and locale, not on every mount: every page
// load mounts the provider, and every authenticated write is charged to the
// account's shared write bucket, so a mount-time PUT spent one write per page
// view on a value that had not changed. A switch still writes at once (the
// locale differs from the one recorded), and a new tab writes once.
//
// Only for a signed-in account. The route answers 401 to nobody, the marker
// is then never set, and every signed-out page load used to repeat the
// refused PUT. Signed out, the choice stays in localStorage and the cookie;
// `LocaleServerSyncBridge` calls this once an account is known, so the
// choice made before signing in reaches the column right after.
const LOCALE_SYNCED_KEY = "healthlog-locale-synced";
function persistLocaleToServer(newLocale: Locale) {
export function persistLocaleToServer(newLocale: Locale, accountId: string) {
const marker = `${accountId}:${newLocale}`;
try {
if (sessionStorage.getItem(LOCALE_SYNCED_KEY) === newLocale) return;
if (sessionStorage.getItem(LOCALE_SYNCED_KEY) === marker) return;
} catch {
// Storage unavailable: write as before.
}
@@ -148,7 +155,7 @@ function persistLocaleToServer(newLocale: Locale) {
.then((res) => {
if (!res.ok) return;
try {
sessionStorage.setItem(LOCALE_SYNCED_KEY, newLocale);
sessionStorage.setItem(LOCALE_SYNCED_KEY, marker);
} catch {
// Storage unavailable: the next mount writes again.
}
@@ -238,15 +245,6 @@ export function I18nProvider({
};
}, [locale]);
// v1.25.0 — keep `User.locale` in step with the committed UI locale. Runs on
// the first mount of a tab session (backfilling the column for users who set
// their language before this landed) and on every switch, so the proactive
// Coach nudge and other cookie-blind background paths render in the language
// the user reads.
useEffect(() => {
persistLocaleToServer(locale);
}, [locale]);
const setLocale = useCallback((newLocale: Locale) => {
if (!(locales as readonly string[]).includes(newLocale)) return;
+7 -1
View File
@@ -217,7 +217,13 @@ async function readWindowMeans(
1,
Math.ceil((to.getTime() - from.getTime()) / MS_PER_DAY),
);
const resolved = await readBestGranularityRollups(userId, type, windowDays);
const resolved = await readBestGranularityRollups(
userId,
type,
windowDays,
undefined,
now,
);
if (
resolved &&
resolved.granularity === "DAY" &&
+7 -1
View File
@@ -274,7 +274,13 @@ export async function readDayMeanSeries(
// band from WEEK/MONTH `sd`, and the center reads the DAY `mean`
// which composes exactly. `readBestGranularityRollups` with a
// window < 91 days resolves to DAY by construction.
const resolved = await readBestGranularityRollups(userId, type, windowDays);
const resolved = await readBestGranularityRollups(
userId,
type,
windowDays,
undefined,
now,
);
if (
resolved &&
resolved.granularity === "DAY" &&
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff. Show more