Compare commits

..
43 Commits
Author SHA1 Message Date
Marc-André Bombeck 9569d1c528 Merge pull request #498 from MBombeck/release/v1.28.41
release/v1.28.41 — complete the translations, guard the gap
2026-07-16 02:49:07 +02:00
Marc-André Bombeck 45fb909c16 chore(release): v1.28.41 — complete the translations, guard the gap
illness.vital.FUNCTIONAL_IMPACT (the recovery-driver track that wins ties
in the illness insights card's driver selection) was referenced but absent
from all six locale bundles, rendering its raw dotted key on a live
surface — the same dynamic-key seam that let the WHO-5/SCI reminder labels
ship untranslated. Added the label in de/en/es/fr/it/pl.

Closed the class structurally: a new enum-driven
dynamic-key-exhaustiveness test imports the source enum/const for each
high-value dynamic key space (illness scan types + functional-impact,
illness type/lifecycle, allergy + family-relationship records enums, the
mental-health instrument slugs — the WHO-5/SCI seam) and asserts every
member's built key resolves non-empty in all six locales, so a missing
translation fails CI instead of reaching a user. Fallback-protected spaces
(Strava raw sport_type) and structurally-unreachable keys are documented
as excluded. Removed one confirmed-orphan key (illness.vital.SKIN_TEMPERATURE,
zero call sites).
2026-07-16 02:35:22 +02:00
Marc-André Bombeck f05354796a Merge pull request #497 from MBombeck/release/v1.28.40
release/v1.28.40 — insight assessments lead with meaning, not a number
2026-07-16 02:25:23 +02:00
Marc-André Bombeck 461c1cfe09 chore(release): v1.28.40 — insight assessments lead with meaning, not a number
The per-metric insight assessments opened on the current value and read as
a data readout, while the overview texts lead with the day's verdict and
land the warm, motivating tone. Diagnosis (campaign insights-copy audit +
verification): identical warm persona/contract fragments, but the
per-metric USER prompt instructed number-first while the overview
instructed verdict-first, and the opener-hint rotation was wired into
briefing/scores/Coach but never the per-metric path (so the base prompt's
'do not open with the number' clause stayed dormant there).

Rewrote the seven per-metric assessment user prompts (generic archetype +
six bespoke: BP/weight/pulse/BMI/mood/compliance + overall) verdict-first
in EN+DE — meaning first, the number right after as support; grounding,
non-diagnostic framing, no-vendor and no-markdown all preserved (only
clause order changed). Wired openerArchetypeHint into metric-status and
the bespoke generators. Factored a shared openingShape contract into
shared-contracts so overview/per-metric/scores can't drift again, with a
coverage test. Lifted the three deterministic first-paint fallbacks to
verdict-led. Prompt-contract regression tests added. No new model calls,
token budget flat.
2026-07-16 02:14:46 +02:00
Marc-André Bombeck 273ead0304 Merge pull request #496 from MBombeck/release/v1.28.39
release/v1.28.39 — sync data-loss hardening
2026-07-16 01:56:12 +02:00
Marc-André Bombeck 7386630962 chore(release): v1.28.39 — sync data-loss hardening
Four verified HIGH data-integrity fixes, each in-place per-provider (no
shared helper), from the campaign's data-integrity audit + adversarial
verification:

F1 — drain-per-sample-cumulative buildScanWhere was the only one of four
sibling drains missing deletedAt: null; the nightly cumulative fold
re-summed soft-deleted Apple samples and hard-deleted the legacy-steps
audit tombstones. Added the filter (mirrors consolidate-daily-mean,
dense-intraday-retention, consolidate-legacy-steps).

F2 — the additive late-fold merge had no single-flight guard, so two
overlapping runs (deploy overlap, or the admin endpoint racing the cron)
produced a permanent 2x daily total. Added a pg_advisory_xact_lock per
(user, externalId) plus an in-transaction re-read of the live
contributors so the serialised-second fold contributes zero (a bare lock
alone would still re-add its stale scanned sum). Mirrors the documents
quota advisory-lock pattern.

F3 — Withings sleep externalId keyed on segment START while measuredAt +
the natural key use END; a re-scored segment missed the externalId probe,
the sweep tombstoned the old row, and the re-keyed insert collided on the
natural key and wedged. Added the natural-key rescue (re-key in place),
mirroring the same-file measure path.

F4 — Withings measure sync stamped lastSyncedAt + recordSyncSuccess
unconditionally after a warn-only per-row loop; a transient write failure
stranded the reading behind the 10-min overlap forever. Now holds the
watermark and records failure on a hard row error (mirrors
google-health/fitbit), while a benign P2002 still advances.

Regression test per finding (reverting the three source files fails
exactly four tests). Integration suite green against real Postgres.
2026-07-16 01:45:05 +02:00
Marc-André Bombeck 51d42f9422 Merge pull request #495 from MBombeck/release/v1.28.38
release/v1.28.38 — documents polish, waist reminders, bulk sharing
2026-07-16 01:05:35 +02:00
Marc-André Bombeck 3e917969c7 fix(documents): surface the read-by-AI provenance in the detail view
v1.28.38 removed the purple AI-read glyph from the vault card (it looked
wrong there), but that glyph had been the only visible surface for the
'read by AI' provenance since v1.28.22 — the detail sheet's AI section
shows no status and collapses entirely under auto-read. Add a discreet
muted 'Read by AI' line under the document's uploaded-metadata in the
detail view (data-slot document-detail-ai-read), independent of the AI
section's collapse logic; reinstate documents.ai.statusAiRead in all six
locales. Card glyph stays removed. The e2e that asserted the card marker
now deep-links the document and asserts the detail-view line.
2026-07-16 00:54:01 +02:00
Marc-André Bombeck 4852421f05 chore(release): v1.28.38 — documents polish, waist reminders, bulk sharing
Document vault: card list drops a stray glyph and gives the real filename
its own line (was truncating on the meta row); the detail sheet's close
button aligns correctly — ResponsiveSheet reserved the right gutter for a
close button it wasn't rendering, now conditional on showCloseButton. The
open-in-coach action reads 'Coach fragen'.

Bulk share: a multi-selection in the overview shares several documents in
ONE link (documents-only privacy preserved, owner-scoped, up to 50 per
link; >50 refuses with a toast rather than silently truncating).

Reminders can link to WAIST_CIRCUMFERENCE (additive MeasurementReminderType
enum value, OpenAPI regenerated). The WHO-5 and SCI well-being reminder
labels were absent in all six locales and rendered their raw dotted keys
(the shared 'measurementReminders.types' prefix is what showed as the stray
'Measurement Reminders Types'); added in every language. A new
type-labels-i18n test asserts every dynamic reminder-type key resolves
non-empty in all six bundles — the static coverage guard cannot see the
dynamic key that let this through.
2026-07-16 00:33:20 +02:00
Marc-André Bombeck ea71ba2f37 Merge pull request #494 from MBombeck/release/v1.28.37
release/v1.28.37 — provider step totals survive a restart
2026-07-15 23:07:17 +02:00
Marc-André Bombeck 0ba5334b57 fix(measurements): legacy step consolidation no longer sweeps provider daily totals
WIP-commit (worktree safeguard; final assembly + version bump happen in the main tree after v1.28.36 merges).
2026-07-15 22:49:07 +02:00
Marc-André Bombeck 8b1c5deac7 Merge pull request #493 from MBombeck/release/v1.28.36
release/v1.28.36 — re-importing after a failed Apple Health import actually retries
2026-07-15 22:34:21 +02:00
Marc-André Bombeck 7a6598f7c3 test(e2e): stabilise the profile-timezone sessions-card expand
The sessions card toggle's onClick could be dropped when Playwright
clicked before React hydrated the handler, leaving the region hidden for
the whole wait and failing intermittently in CI. Retry the click until
aria-expanded actually flips.
2026-07-15 22:23:04 +02:00
Marc-André Bombeck 82aa66b400 chore(release): v1.28.36 — re-importing after a failed Apple Health import actually retries
The v1.28.33 rollup + staging fixes were correct but never reached the
reporter's case: the upload dedup keys on the content SHA-256 and the
lookup matched a previously FAILED job too, so re-uploading the same
export.zip returned the dead job's id and replayed its stale
failureReason without ever staging or sending again. The dedup now
excludes failed rows (short-circuits only on queued/unpacking/parsing/
upserting/done); the same bytes retry cleanly. A redundant staged upload
is unlinked on a dedup hit instead of leaking in the staging dir.

reconcileOrphanImportJobs no longer unconditionally fails every
non-terminal ImportJob at boot: it now checks pg-boss job state plus a
progress heartbeat (new import_jobs.updated_at, migration 0242), so a
split or multi-worker deployment cannot kill an import running in
another worker. The missing-staging message names the separate-container
cause explicitly, with a matching note in docs/self-hosting/scaling.md.
2026-07-15 22:07:25 +02:00
Marc-André Bombeck a614542cc3 Merge pull request #485 from balajiv113/feat/oidc-sso-login
feat(auth): add OIDC SSO login
2026-07-15 21:37:20 +02:00
Marc-André Bombeck 132c113305 Merge pull request #491 from MBombeck/release/v1.28.35
release/v1.28.35 — timestamps honor the profile timezone
2026-07-14 20:32:36 +02:00
Marc-André Bombeck 9bbd8a89dc chore(release): v1.28.35 — timestamps honor the profile timezone
Client-rendered times fell back to the hardcoded display zone
(Europe/Berlin) because neither useFormatters() nor the legacy format.ts
helpers ever received users.timezone. A third localStorage mirror
(timezone-mirror.ts, following the time-format/date-format pattern)
carries the validated profile zone from /api/auth/me into both read
paths; the fallback chain is strictly profile → Europe/Berlin — never
the browser zone, so screen, PDF, and export cannot disagree.
makeFormatters validates its zone before Intl (a poison value falls back
instead of throwing inside every render).

Same pass, per the two-document audit: formatUpdatedLabel boundary and
clock now resolve one zone in every state (the undefined-tz browser hole
is closed); @db.Date calendar fields render UTC-pinned so profiles west
of UTC cannot shift a day; dose-history day headings and GLP-1 weekday
labels derive from the display zone; chart day/month labels are
encoding-derived and UTC-pinned (bucket math untouched, byte-identical
for Berlin profiles); the clinician share view renders in the patient's
timezone instead of the container's; Playwright pins Europe/Berlin.
Server day-key semantics, streak anchoring, insight cache keys, and
verbatim wall-clock strings are untouched by construction and pinned by
the new Auckland/New_York/Manila + DST test matrix.
2026-07-14 20:19:23 +02:00
Marc-André Bombeck 136653e3d7 Merge pull request #483 from MBombeck/dependabot/npm_and_yarn/shadcn-4.13.0
chore(deps-dev): bump shadcn from 4.9.0 to 4.13.0
2026-07-14 13:31:50 +02:00
Marc-André Bombeck 1e8d796c57 Merge pull request #489 from MBombeck/release/v1.28.34
release/v1.28.34 — nutrient intake lands as quiet context (server side)
2026-07-14 13:22:33 +02:00
Marc-André Bombeck 5445fc67ea chore(release): v1.28.34 — nutrient intake lands as quiet context (server side)
Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).

Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.
2026-07-14 13:10:47 +02:00
dependabot[bot] a074286951 chore(deps-dev): bump shadcn from 4.9.0 to 4.13.0
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.9.0 to 4.13.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.13.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 10:28:31 +00:00
Marc-André Bombeck 14a9a08a6e Merge pull request #482 from MBombeck/dependabot/npm_and_yarn/types/node-26.1.1
chore(deps-dev): bump @types/node from 26.1.0 to 26.1.1
2026-07-14 12:26:33 +02:00
Marc-André Bombeck 0b7d07dce4 Merge pull request #481 from MBombeck/dependabot/npm_and_yarn/lefthook-2.1.10
chore(deps-dev): bump lefthook from 2.1.9 to 2.1.10
2026-07-14 12:26:30 +02:00
Marc-André Bombeck bdae462948 Merge pull request #480 from MBombeck/dependabot/npm_and_yarn/simplewebauthn/server-13.3.2
chore(deps): bump @simplewebauthn/server from 13.3.1 to 13.3.2
2026-07-14 12:26:28 +02:00
Marc-André Bombeck bb1008f1a1 Merge pull request #478 from MBombeck/dependabot/github_actions/docker/login-action-4.4.0
chore(deps): bump docker/login-action from 4.2.0 to 4.4.0
2026-07-14 12:26:25 +02:00
Marc-André Bombeck ac1105751d Merge pull request #488 from MBombeck/release/v1.28.33
release/v1.28.33 — re-importing a cumulative Apple Health export works again
2026-07-14 12:09:13 +02:00
Marc-André Bombeck 6d8fe06148 chore(release): v1.28.33 — re-importing a cumulative Apple Health export works again
Rollup writes were delete-then-createMany inside a transaction; under READ
COMMITTED a concurrent recompute racing the import's full fold on the same
(user, type, granularity, bucket, source) partition tripped the composite
PK — and the P2002 swallow dropped the loser's freshly computed aggregate,
leaving a stale bucket. persistRollupRows now writes chunked
INSERT … ON CONFLICT DO UPDATE with deterministic (type, source, bucket)
lock ordering inside one transaction: a race can neither fail the import
nor lose an aggregate, and previously-staled buckets self-heal on their
next recompute. The vanished-source delete and in-transaction guarantees
are preserved; the now-unreachable P2002 swallow is removed.

The masked ENOENT is gone with it: long imports outlived the queue's
15-minute expiry, pg-boss redelivered the still-running job, and the
redelivery re-opened the consumed staging file — overwriting the real
terminal state with a raw ENOENT. Import sends now carry retryLimit 0 +
6h expiry (a retry after the staging file is consumed can only mask),
deliveries for terminal ImportJob rows are no-ops, a genuinely missing
staging file reports an honest re-upload message, and the extracted XML
is cleaned up on failure too.
2026-07-14 11:56:29 +02:00
Marc-André Bombeck b57c517929 docs: self-hosting SSO guide
Setup walk (byte-exact redirect URI), client-registration essentials
for Authentik / Keycloak / Authelia / Pocket-ID, the security model
(identity pinned to (issuer, sub) after a one-time verified-email link;
SSO sessions never satisfy step-up; native-2FA accounts still get their
second-factor step; MFA policy belongs at the IdP), the OIDC_ONLY
consequences incl. the iOS lockout and the break-glass runbook, and a
troubleshooting table for the recurring failure shapes (issuer
trailing slash, unfollowed discovery redirects, clock skew,
IdP-side redirect_uri mismatch).
2026-07-14 11:55:47 +02:00
Marc-André Bombeck 7592c73947 fix(auth): harden the OIDC token path and state-cookie lifecycle
Token verification: jwtVerify now carries a closed asymmetric algorithm
allowlist (RS256/PS256/ES256/EdDSA — an HMAC alg would let anyone with
the client secret forge identities) and a 60 s clock tolerance for the
usual self-host drift between IdP and app box. The JWKS fetch — jose's
own, outside safeFetch by design — is disciplined instead by pinning
the jwks_uri host to the configured issuer's host byte-exact and
passing the same 15 s timeout safeFetch defaults to. requirePublicHost
stays deliberately unused on this surface: LAN/Tailscale-only IdPs are
a legitimate topology and the issuer is operator config, not user
input.

Discovery: the issuer comparison now tolerates exactly one trailing
slash on either side (a recurring IdP/operator mismatch); token
verification still matches the provider-sent issuer byte-exact.

State cookie: the cookie is set under Path=/api/auth/oidc but was
deleted without a path — RFC 6265 keys cookies by (name, domain,
path), so the deletion never matched and the single-use blob survived
its whole TTL. Every delete now repeats the exact path, via a shared
constant.

Also: the IdP error code recorded in the wide event is whitelisted
against the closed OAuth error-code set (unknown values collapse to
"other"); the status route drops an unreachable try/catch and a
duplicated annotate; the passkey conditional-UI autofill stays unarmed
under OIDC_ONLY (passkey login is a server-enforced dead end there);
env-manifest gains the three optional OIDC_* tuning vars; the env
example notes why the issuer should be HTTPS.
2026-07-14 11:53:53 +02:00
Marc-André Bombeck 9b8b31c864 fix(auth): OIDC sessions never satisfy MFA step-up; 2FA accounts get the challenge step
Two second-factor gaps in the OIDC callback:

The session was minted with mfaVerifiedAt stamped to the login instant,
letting a bare SSO login satisfy requireFreshMfa step-up (MFA disable,
encryption-key rotation, encrypted export, account deletion). OIDC is a
delegated factor — whatever the IdP checked, this app did not see a
second factor — so the stamp is now null, the same treatment a
trusted-device login gets.

An account with a confirmed TOTP secret or a registered security key
was logged straight in, silently downgrading its native 2FA to
IdP-only. The callback now mints the same single-use MFA ticket
password login uses and hands it to the login page through a
short-lived, login-page-scoped cookie (the callback ends on a browser
redirect, so the ticket cannot ride an envelope meta like the password
flow). The login page picks it up once, clears it, and renders the
same challenge step; the login completes at /api/auth/mfa/verify with
identical continuation semantics. The cookie is deliberately not
httpOnly — the login page script must read the ticket, exactly the
exposure the password flow already has — and the ticket alone carries
no session authority (single-use, hashed at rest, 5-minute TTL,
attempt-capped).

Direct (factor-less) OIDC logins now also sync the admin-enforced-MFA
enrollment hint cookie, matching password login.
2026-07-14 11:46:40 +02:00
Marc-André Bombeck fa326c398a feat(auth): pin OIDC accounts to their (issuer, sub) identity
An OIDC login now resolves the account by the durable (issuer, sub)
pair instead of the email claim. First contact stamps the pair — by
auto-provision or a one-time link against an existing account matched
by verified email — and every later login matches on it alone, so an
IdP-side email change refreshes the display email but can never
re-point the login at a different account. An email that matches an
account already bound to a different identity is rejected and audited
(auth.oidc.link_conflict), never silently re-bound.

The email gate tightens with it: linking and provisioning now require
an explicit email_verified: true — an absent claim is a reject
(oidc_email_unverified) rather than a benefit of the doubt, since an
IdP that does not assert verification cannot anchor the account match.
Provisioned accounts get a lowercased email and the same server-default
timezone resolution the register route uses.

Migration 0240 adds the nullable users.oidc_issuer / users.oidc_sub
columns with a composite unique index.
2026-07-14 11:43:53 +02:00
Marc-André Bombeck 5b7ea48c46 docs: PR template targets main — the develop branch was retired 2026-07-14 10:36:25 +02:00
Balaji Vijayakumar f815e4a83f feat(auth): add OIDC SSO login
Adds an OpenID Connect relying-party login path (PKCE authorization-code
flow) against a single env-configured identity provider — additive to
password/passkey login by default, with an operator opt-in (OIDC_ONLY,
enforced server-side on every auth route) to disable them entirely.
First-time sign-in auto-provisions or links an account by verified email.

Closes an open redirect in the post-login `next` parameter (both the new
OIDC path and the pre-existing password/passkey flow shared the same
backslash-normalization bypass) by resolving the target through a real
URL parse and comparing origins instead of a prefix check.
2026-07-13 16:33:13 +05:30
Marc-André Bombeck 6712619495 Merge pull request #484 from MBombeck/release/v1.28.32
release/v1.28.32 — Fitbit: re-keyed re-imports can no longer be silently dropped
2026-07-12 20:42:48 +02:00
Marc-André Bombeck a70139c16a chore(release): v1.28.32 — Fitbit re-keyed re-imports can no longer be silently dropped
Single-fix release mirroring the Google Health natural-key rescue from
v1.28.28 into the Fitbit sync. The measurements natural-key unique index
(user, type, measured_at, source, sleep_stage — NULLS NOT DISTINCT,
migration 0055) covers tombstoned rows, so a re-import carrying NEW
externalIds for previously-removed rows collided on the natural key and
skipDuplicates dropped the insert silently. Every planned create now gets
a second chunked probe by natural key with no deletedAt filter; a match —
live or tombstoned — is updated in place with the fresh value, the new
externalId, and deletedAt: null. Probe failure notes the hard-failure
ledger so the watermark holds and the next tick retries. No other code
path touched.
2026-07-12 20:30:12 +02:00
dependabot[bot] c41ca511cb chore(deps-dev): bump @types/node from 26.1.0 to 26.1.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.1.0 to 26.1.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:55:10 +00:00
dependabot[bot] 9ba34befce chore(deps-dev): bump lefthook from 2.1.9 to 2.1.10
Bumps [lefthook](https://github.com/evilmartians/lefthook) from 2.1.9 to 2.1.10.
- [Release notes](https://github.com/evilmartians/lefthook/releases)
- [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/evilmartians/lefthook/compare/v2.1.9...v2.1.10)

---
updated-dependencies:
- dependency-name: lefthook
  dependency-version: 2.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:54:35 +00:00
dependabot[bot] a21dc50cab chore(deps): bump @simplewebauthn/server from 13.3.1 to 13.3.2
Bumps [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) from 13.3.1 to 13.3.2.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.2/packages/server)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:53:54 +00:00
dependabot[bot] 4dfb207a6d chore(deps): bump docker/login-action from 4.2.0 to 4.4.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...af1e73f918a031802d376d3c8bbc3fe56130a9b0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:52:32 +00:00
Marc-André Bombeck 14b61e1084 Merge pull request #477 from MBombeck/release/v1.28.31
release/v1.28.31 — heart-rate history keeps its daily shape
2026-07-12 12:19:23 +02:00
Marc-André Bombeck 1375d20a9d chore(release): v1.28.31 — heart-rate history keeps its daily shape
Long-term retention for the dense intra-day tier (PULSE, HRV, SpO2) folds
to per-local-hour means instead of a single daily mean: the last 14 days
keep every raw sample, older days keep up to 24 points per metric — the
day's shape survives permanently at ~9k rows/metric/year. Hourly rows key
as stats:<HK>:<YYYY-MM-DD>T<HH> anchored at local HH:30, adopt-in-place
against both unique indexes with the P2002 retry outside the transaction.
The pre-fold DAY-rollup recompute extends to HRV (mean fidelity, not only
min/max) and the post-fold recompute is gone for the whole tier. Derived
resting rows for proxy users still mint from the day's raw samples.

A one-shot rebuild reconstructs the hourly shape for every already-folded
day from the soft-deleted raw samples and retires the daily row in the
same transaction — at no instant are both grains live, so no
AVG-over-live-rows reader can double-count. Boot discovery pairs live
daily-grain rows with surviving tombstones (parameter-bound SQL, pinned
against real Postgres) and converges to zero once converted; days whose
tombstones were pruned keep their daily mean. PULSE/SpO2 DAY buckets keep
the true pre-fold stats; HRV DAY buckets recompute from the hourly rows
(strictly better than the collapsed fold-time bucket); WEEK/MONTH/YEAR
respans enqueue whole-bucket recomputes.
2026-07-12 12:07:41 +02:00
Marc-André Bombeck 85267f15b3 Merge pull request #476 from MBombeck/release/v1.28.30
release/v1.28.30 — the daily briefing stops silently skipping days
2026-07-12 11:20:46 +02:00
Marc-André Bombeck a4feabec81 chore(release): v1.28.30 — the daily briefing stops silently skipping days
The recurring no-briefing day had a chain of causes, closed end to end:

Every failure path in the nightly pregenerate + the comprehensive generator
now annotates a queryable cause (several classes — features-error,
payload-too-large, aborted, generator-failed — previously bumped a counter
with zero trace). A failed nightly warm enqueues one bounded retry ~45
minutes later (singleton per user, existing budgets apply), so a transient
provider hiccup heals before morning. A cached payload that carries NO
briefing no longer satisfies the 24h POST short-circuit nor the
unchanged-content hash gate — a once-stripped briefing could previously
block its own regeneration indefinitely. The card surfaces the omission
reason persistently (grounding strip, generation failure, not attempted)
with a forcing retry.

Documentation corrected against code truth: Withings credentials are
per-user (the documented env path never existed; the dead example entries
and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually
whitelisted in compose — stock deploys could never deliver it); the classic
Fitbit portal no longer accepts new registrations; the backup-restore
example no longer mints a fresh encryption key and uses pnpm dlx tsx; the
TLS guide describes the real client model (two CA-level SPKI pins,
any-match, leaf renewals need no build); scaling/admin references fixed;
the operator-shared subscription provider is documented.
2026-07-12 11:08:59 +02:00
211 changed files with 15462 additions and 1530 deletions

No files matched your search

+31 -3
View File
@@ -29,10 +29,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# Withings OAuth -- optional integration
# Withings -- optional integration
# -----------------------------------------------------------------------------
# WITHINGS_CLIENT_ID=""
# WITHINGS_CLIENT_SECRET=""
# The Withings OAuth client id/secret are per-user: each user registers their
# own Withings developer app and pastes the pair into Settings (stored
# encrypted in the DB). There is therefore no WITHINGS_CLIENT_ID/SECRET.
# WITHINGS_WEBHOOK_SECRET=""
@@ -102,6 +103,33 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# STRAVA_CLIENT_SECRET=""
# STRAVA_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# OIDC SSO login -- optional (self-hosted identity provider)
# -----------------------------------------------------------------------------
# Register a confidential OIDC client for your IdP (Authentik, Keycloak,
# Authelia, Google Workspace, etc.) with redirect URI
# `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback`. All three of ISSUER_URL/
# CLIENT_ID/CLIENT_SECRET together enable the "Sign in with SSO" button on
# the login page; set none to leave it disabled. First-time sign-in
# auto-provisions (or links, by verified email) a HealthLog account. OIDC_ONLY
# hides password/passkey login entirely once set to "true" -- but only takes
# effect when the three vars above are also fully set, so a typo can't lock
# every user out. Enforced server-side, not just a hidden button. The native
# iOS client authenticates via password/passkey only -- there is no OIDC
# flow for it yet -- so OIDC_ONLY="true" locks the iOS app out of sign-in
# entirely until a native SSO flow ships. Do not enable it on a deployment
# with active iOS users.
# Serve the issuer over HTTPS wherever possible: sign-in material (the
# authorization code, the token-endpoint response carrying the ID token)
# crosses this connection. Plain HTTP is defensible only on a fully trusted
# private network (IdP and app on the same box, a Tailscale mesh, a VPN).
# OIDC_ISSUER_URL=""
# OIDC_CLIENT_ID=""
# OIDC_CLIENT_SECRET=""
# OIDC_SCOPES="openid email profile"
# OIDC_BUTTON_LABEL="Single Sign-On"
# OIDC_ONLY="false"
# -----------------------------------------------------------------------------
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
+1 -1
View File
@@ -28,7 +28,7 @@
## Checklist
- [ ] Targets the `develop` branch (not `main` — see [CONTRIBUTING.md](../CONTRIBUTING.md))
- [ ] Targets the `main` branch (trunk-based — see [CONTRIBUTING.md](../CONTRIBUTING.md))
- [ ] `pnpm typecheck` passes locally
- [ ] `pnpm lint` passes locally
- [ ] `pnpm test` passes locally
+2 -2
View File
@@ -98,7 +98,7 @@ jobs:
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -293,7 +293,7 @@ jobs:
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
fi
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+191
View File
@@ -2,6 +2,197 @@
## [Unreleased]
## [1.28.41] — 2026-07-16 — Complete the translations, and guard the gap
One reminder-adjacent label — the recovery-driver "functional impact" track on
the illness insights card — was showing its internal name instead of a
translation in every language; it is now translated in all six. The class of
bug behind it (a label resolved through a computed key the static check can't
see, so a missing translation ships silently — the same seam as the well-being
labels fixed a release ago) is now closed structurally: a new test walks each
such key space against every language bundle, so a missing translation fails
the build instead of reaching a user.
## [1.28.40] — 2026-07-16 — Insight assessments lead with meaning, not a number
The per-metric insight assessments read like a data readout — they opened on
the current value. The overview texts already lead with the day's verdict in
plain words and land the warm, motivating tone; the per-metric cards were told
the opposite (name the level with a number first) and never received the
opener-variation the other insight surfaces use. Now every metric assessment
opens with what the reading MEANS and brings the number in right after as
support, in the same warm register as the overview. A shared opening-shape
contract keeps the surfaces from drifting apart again, and the deterministic
first-paint fallbacks lead with the verdict too. No change to what the numbers
say — only how the sentence is built.
## [1.28.39] — 2026-07-16 — Sync data-loss hardening
Four fixes to sync paths that could lose or corrupt data in edge cases:
- The nightly pass that folds raw cumulative samples (steps, energy, distance)
into daily totals no longer re-counts a sample you had deleted — it was the
one such pass missing the deleted-row filter its siblings all carry.
- That same pass is now safe when two runs overlap (a deploy-timing overlap, or
a manual admin trigger racing the nightly job): an advisory lock plus an
in-transaction re-read make the fold idempotent, so an overlap can no longer
permanently double a day's total.
- A re-scored Withings sleep segment (its end time shifts) now updates the
existing row in place instead of colliding with it and wedging that night —
the same re-key rescue the other providers already carry.
- Withings measurement sync now holds its position when a reading fails to
write, instead of advancing past it and stranding the reading; the next sync
retries it.
## [1.28.38] — 2026-07-16 — Documents polish, waist reminders, bulk sharing
The document vault gets a round of overdue polish. The list cards drop a
stray glyph — the "read by AI" mark it carried moves into the document's
detail view — and give the real filename its own line so it stops being cut
off; the detail view's close button is aligned correctly (a shared sheet
primitive was reserving space for a button it wasn't showing). The action
that opens a document in the coach now reads "Coach fragen".
From a multi-selection in the overview you can now share several documents
at once — one link holding the selection, with the same documents-only
privacy as a single share (up to 50 per link).
Reminders can now be linked to waist circumference. And the well-being
reminder types (WHO-5, sleep-condition) that were showing their internal
names instead of a label are now translated in every language.
## [1.28.37] — 2026-07-15 — Provider step totals survive a restart
A boot-time maintenance pass that folds genuinely old raw step samples into
daily totals had too broad a reach: it also matched the daily step totals that
Google Health, Fitbit, and Withings write, and soft-deleted them on every
worker start. Recent days came back on the next sync, but older days stayed
gone until a full re-sync — which the next restart swept again. The pass now
skips any row that is already a daily total and only ever touches the ingest
paths that produced real legacy raw samples, so a provider's step history is
left alone. A one-time repair restores the totals removed by earlier restarts
(within the retention window; a single full sync recovers anything older) and
cleans up the placeholder totals the bug minted in their place.
## [1.28.36] — 2026-07-15 — Re-importing after a failed Apple Health import actually retries
The v1.28.33 rollup and staging fixes were correct but never reached the
reporter's case: the upload deduplicates by content hash, and the lookup
matched a previously FAILED job too — so re-uploading the same export.zip
returned the old failed job and replayed its stale error without ever running
the import again. The dedup now ignores failed jobs; the same file retries
cleanly. A redundant staged upload is also cleaned up on a dedup hit instead
of lingering in the staging directory.
Two related hardenings: the boot-time reconcile that marks interrupted imports
failed now performs a real liveness check (queue state plus a progress
heartbeat) instead of unconditionally failing every in-flight job — so a split
or multi-worker deployment no longer kills an import running in another worker.
And the "staging file not found" message now names the separate-container cause
explicitly, with a matching note in the scaling guide.
## [1.28.35] — 2026-07-14 — Timestamps honor the profile timezone
Client-rendered times previously fell back to a hardcoded display zone
(Europe/Berlin) no matter what the profile timezone said; a user in Manila saw
Berlin clock times on every card, chart, and table. Display now follows the
profile timezone everywhere, with a strict fallback to the legacy display zone
— never the browser zone, so screen, PDF, and export can no longer disagree.
An invalid stored timezone can never break rendering; it falls back safely.
The same pass closed the latent inconsistencies the audit surfaced: the
"Updated today" cards no longer mix one zone for the day boundary and another
for the clock; calendar-date fields no longer shift a day for profiles west of
UTC; the dose-history day headings and weekday labels render in the display
zone; chart day/month labels stay correct for every zone (bucket math is
untouched); and the clinician share view renders in the patient's timezone
instead of the server container's. Day groupings, schedules, and aggregates
were already timezone-correct server-side and are byte-identical.
## [1.28.34] — 2026-07-14 — Nutrient intake lands as quiet context (server side)
Supplement-style intake written to Apple Health by nutrition apps — vitamins,
minerals, water, caffeine — can now sync to HealthLog as daily totals. This is
deliberately not a nutrition feature: no food diary, no calorie tracking, no
dashboard tile. The data lands in a dedicated store (26 nutrients with EFSA
reference values), is visible on a read-only card under Settings → Sources,
and will feed the Coach as context in a later release.
The module is opt-in and off by default; with it off, the server refuses the
data entirely. Re-posting a day replaces it, units are guarded against
µg/mg mix-ups, and implausible values are skipped per entry. This release is
inert until the iOS companion app ships its reading side (coordination ticket
filed); the wire contract is in the OpenAPI spec.
## [1.28.33] — 2026-07-14 — Re-importing a cumulative Apple Health export works again
Re-uploading a fresh export.zip over existing data could fail with a duplicate-key
error in the rollup tier: a concurrent recompute (a live sync, the nightly drain)
racing the import's full fold could collide on the same aggregation bucket — and
the losing side silently dropped its freshly computed aggregate, leaving a stale
bucket. Rollup writes are now conflict-safe upserts with deterministic lock
ordering: a race can neither fail the import nor lose an aggregate, and buckets
left stale by earlier races self-heal on their next recompute.
The misleading error is gone too. A long import could outlive its queue timeout;
the redelivery re-opened the already-consumed staging file and overwrote the real
failure — or even a successful completion — with a raw ENOENT. Import jobs now
finish terminally (no redelivery after the staging file is consumed, six-hour
timeout), a delivery for an already-finished import is a no-op, and a genuinely
missing staging file reports an honest "upload the export again" message.
## [1.28.32] — 2026-07-12 — Fitbit: re-keyed re-imports can no longer be silently dropped
Single-fix release for the Fitbit integration, mirroring the Google Health
fix from v1.28.28. The measurements table carries a second unique index on
the natural key (type, time, source, sleep stage) that also covers
soft-deleted rows; when an import carries new external ids for rows that
were previously removed — the re-keyed re-import case — the insert collided
with the old row and was dropped without a trace. Planned inserts now get a
second probe by natural key: a match, live or removed, is updated in place
with the fresh value and the new id instead. If the probe itself fails, the
sync reports the failure and holds its watermark so the next run retries —
nothing is lost either way. No other code path is touched in this release.
## [1.28.31] — 2026-07-12 — Heart-rate history keeps its daily shape
Long-term retention for the dense intra-day signals (heart rate, HRV, blood
oxygen) now folds to hourly means instead of a single daily mean. The last
14 days keep every raw sample as before; older days keep up to 24 points —
enough to see how a day actually went, at roughly nine thousand rows per
metric per year instead of an unbounded raw stream.
Because the old fold soft-deleted rather than erased, a one-time rebuild
reconstructs the hourly shape for every already-folded day from the retained
raw samples, then retires the old daily row in the same transaction — no
reader can ever double-count a day. The rebuild runs automatically once per
installation after the update; days whose raw samples are no longer
available keep their daily mean. Daily min/max/mean statistics captured at
fold time stay untouched throughout.
## [1.28.30] — 2026-07-12 — The daily briefing stops silently skipping days
The recurring "no briefing today" had a chain of causes, now closed end to end.
Every failure path in the nightly generation names its cause in the logs
(several classes previously failed without a trace). A failed night retries
itself about forty-five minutes later instead of waiting for the next night.
A cached insight payload that carries no briefing no longer counts as fresh —
previously it could satisfy the daily window and even the unchanged-data check,
so a once-stripped briefing could block its own regeneration indefinitely. And
the briefing card now reports persistently why a briefing is absent (withheld
by the number check, generation failed, or not yet attempted) with a retry
button that always generates.
Documentation corrections, verified against the code: Withings credentials are
per-user in the app (the documented environment-variable path never existed —
and the webhook secret is now actually passed through in the compose file);
the classic Fitbit portal no longer accepts new registrations (new users:
Google Health); the backup-restore example no longer generates a fresh
encryption key (which could never decrypt an existing backup) and uses the
invocation that works in the production image; the certificate-pinning guide
now describes the real model (two CA-level pins, leaf renewals need no app
build); scaling and admin-endpoint references corrected.
## [1.28.29] — 2026-07-11 — Dashboard edits show up on the way back; one scrollbar again
Changing the dashboard tile selection now shows up immediately when you
+1
View File
@@ -165,6 +165,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
## DO-NOTs
+16
View File
@@ -81,6 +81,7 @@ services:
# docker-compose reads .env for `${VAR}` substitution at compose-up time).
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
NATIVE_CANVAS: "${NATIVE_CANVAS:-}"
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
# the real visitor IP is in `cf-connecting-ip`; set
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
@@ -141,6 +142,21 @@ services:
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
# hides password/passkey login entirely, but only takes effect when
# the three above are also fully set — a half-configured group never
# locks every user out. It IS enforced server-side, and there is no
# OIDC flow for the native iOS client yet — enabling it locks iOS
# users out of sign-in entirely. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}"
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
OIDC_SCOPES: "${OIDC_SCOPES:-}"
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
OIDC_ONLY: "${OIDC_ONLY:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
+3 -1
View File
@@ -21,7 +21,9 @@ notes, and machine-readable specs that ship alongside the source:
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
Claude Desktop at it.
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
— OIDC SSO login: IdP setup, the identity-pinning security model,
`OIDC_ONLY` consequences, and the break-glass runbook.
- [`migration/`](./migration/) — release-by-release migration notes.
Read the entry for the version you're upgrading from.
- [`audit/`](./audit/) — per-release audit summaries archived for
+334 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.29
version: 1.28.41
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -10355,6 +10355,128 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/nutrients/batch:
post:
tags:
- Nutrients
summary: Ingest micronutrient day totals (v1.28)
description: >-
Bulk upsert of supplement-style daily totals synced from HealthKit — vitamins, minerals, water, caffeine. Day
totals only: compute HKStatisticsCollectionQuery cumulative sums day-anchored in the user's current IANA
timezone; `day` is yyyy-MM-dd in that timezone (the same rule as the `stats:` day keys). Never post raw samples.
Upsert key is (day, nutrient): a re-post replaces the stored total and reports `updated`. Post the current +
previous local day per sync; backfill 30 days on first enable. Max 500 entries; Idempotency-Key supported; rate
limit 60/min. Per-entry skips (`unit_mismatch` | `value_out_of_range` | `day_invalid`) are terminal — log and
drop, do not retry the entry. Behind the opt-in `nutrients` module (403 `module.disabled` when off). Query
HealthKit with exactly the listed unit per code (`ug` = microgram; do not convert to IU):
| code | HealthKit identifier | unit |
| --- | --- | --- |
| vitamin_a | HKQuantityTypeIdentifierDietaryVitaminA | ug |
| thiamin | HKQuantityTypeIdentifierDietaryThiamin | mg |
| riboflavin | HKQuantityTypeIdentifierDietaryRiboflavin | mg |
| niacin | HKQuantityTypeIdentifierDietaryNiacin | mg |
| pantothenic_acid | HKQuantityTypeIdentifierDietaryPantothenicAcid | mg |
| vitamin_b6 | HKQuantityTypeIdentifierDietaryVitaminB6 | mg |
| biotin | HKQuantityTypeIdentifierDietaryBiotin | ug |
| folate | HKQuantityTypeIdentifierDietaryFolate | ug |
| vitamin_b12 | HKQuantityTypeIdentifierDietaryVitaminB12 | ug |
| vitamin_c | HKQuantityTypeIdentifierDietaryVitaminC | mg |
| vitamin_d | HKQuantityTypeIdentifierDietaryVitaminD | ug |
| vitamin_e | HKQuantityTypeIdentifierDietaryVitaminE | mg |
| vitamin_k | HKQuantityTypeIdentifierDietaryVitaminK | ug |
| calcium | HKQuantityTypeIdentifierDietaryCalcium | mg |
| iron | HKQuantityTypeIdentifierDietaryIron | mg |
| magnesium | HKQuantityTypeIdentifierDietaryMagnesium | mg |
| phosphorus | HKQuantityTypeIdentifierDietaryPhosphorus | mg |
| zinc | HKQuantityTypeIdentifierDietaryZinc | mg |
| copper | HKQuantityTypeIdentifierDietaryCopper | mg |
| manganese | HKQuantityTypeIdentifierDietaryManganese | mg |
| selenium | HKQuantityTypeIdentifierDietarySelenium | ug |
| chromium | HKQuantityTypeIdentifierDietaryChromium | ug |
| molybdenum | HKQuantityTypeIdentifierDietaryMolybdenum | ug |
| iodine | HKQuantityTypeIdentifierDietaryIodine | ug |
| water | HKQuantityTypeIdentifierDietaryWater | ml |
| caffeine | HKQuantityTypeIdentifierDietaryCaffeine | mg |
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NutrientBatchRequest"
responses:
"200":
description: Batch processed; per-entry statuses inserted | updated | skipped.
content:
application/json:
schema:
$ref: "#/components/schemas/NutrientBatchEnvelope"
"401": *a1
"403": &a17
description: The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do
not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the
module is on.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/nutrients:
get:
tags:
- Nutrients
summary: Synced-nutrient window summary (v1.28)
description: "Per-nutrient summary of the last `days` days (default 14): latest synced day + total and the
days-with-data count, in catalog order. Read-only; feeds the settings card. Behind the opt-in `nutrients`
module."
parameters:
- in: query
name: days
schema:
default: 14
type: integer
minimum: 1
maximum: 365
responses:
"200":
description: The window summary.
content:
application/json:
schema:
$ref: "#/components/schemas/NutrientOverviewEnvelope"
"401": *a1
"403": *a17
"422": *a3
"429": *a4
components:
schemas:
EncryptedExportRequest:
@@ -11501,6 +11623,8 @@ components:
type: boolean
mentalHealth:
type: boolean
nutrients:
type: boolean
additionalProperties: false
description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched,
`false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a
@@ -11968,6 +12092,7 @@ components:
- TOTAL_BODY_WATER
- VISCERAL_FAT
- BODY_MASS_INDEX
- WAIST_CIRCUMFERENCE
- PHQ9_SCORE
- GAD7_SCORE
- WHO5_SCORE
@@ -12143,6 +12268,76 @@ components:
- updatedAt
description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal
state.
NutrientBatchRequest:
type: object
properties:
entries:
minItems: 1
maxItems: 500
type: array
items:
$ref: "#/components/schemas/NutrientIntakeEntry"
required:
- entries
description: Bulk day-total upsert, max 500 entries. Upsert key is (day, nutrient); a re-post replaces the stored total
(last-writer-wins day-total contract).
NutrientIntakeEntry:
type: object
properties:
day:
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
nutrient:
$ref: "#/components/schemas/NutrientCode"
unit:
type: string
minLength: 1
maxLength: 10
amount:
type: number
minimum: 0
externalSourceVersion:
type: string
minLength: 1
maxLength: 120
required:
- day
- nutrient
- unit
- amount
description: One nutrient day total. day = YYYY-MM-DD in the user's IANA timezone; unit must equal the catalog's
canonical unit for the code (mg | ug | ml) or the entry is skipped unit_mismatch.
NutrientCode:
type: string
enum:
- vitamin_a
- thiamin
- riboflavin
- niacin
- pantothenic_acid
- vitamin_b6
- biotin
- folate
- vitamin_b12
- vitamin_c
- vitamin_d
- vitamin_e
- vitamin_k
- calcium
- iron
- magnesium
- phosphorus
- zinc
- copper
- manganese
- selenium
- chromium
- molybdenum
- iodine
- water
- caffeine
description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy,
macros and sodium/potassium are out of scope.
MedicationTreatmentClass:
type: string
enum:
@@ -19022,6 +19217,9 @@ components:
mentalHealth:
type: boolean
description: Whether the "mentalHealth" module is enabled for this account.
nutrients:
type: boolean
description: Whether the "nutrients" module is enabled for this account.
required:
- cycle
- mood
@@ -19040,6 +19238,7 @@ components:
- mcp
- inboundDocuments
- mentalHealth
- nutrients
additionalProperties: false
description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means
the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`;
@@ -28359,6 +28558,140 @@ components:
- progress
additionalProperties: false
description: The persisted completion flag and resume point after the write.
NutrientBatchEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/NutrientBatchResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
NutrientBatchResponse:
type: object
properties:
processed:
type: integer
minimum: 0
maximum: 9007199254740991
inserted:
type: integer
minimum: 0
maximum: 9007199254740991
updated:
type: integer
minimum: 0
maximum: 9007199254740991
skipped:
type: array
items:
type: object
properties:
index:
type: integer
minimum: 0
maximum: 9007199254740991
reason:
type: string
required:
- index
- reason
additionalProperties: false
entries:
type: array
items:
$ref: "#/components/schemas/NutrientEntryResult"
required:
- processed
- inserted
- updated
- skipped
- entries
additionalProperties: false
description: Always 200 when the envelope parses; per-entry failures surface as skipped entries, never a batch failure.
NutrientEntryResult:
type: object
properties:
index:
type: integer
minimum: 0
maximum: 9007199254740991
status:
type: string
enum:
- inserted
- updated
- skipped
reason:
type: string
required:
- index
- status
additionalProperties: false
description: "Per-entry ingest outcome. skipped reasons: unit_mismatch | value_out_of_range | day_invalid |
upsert_failed. Log and drop a skipped entry — do not retry it."
NutrientOverviewEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/NutrientIntakeOverview"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
NutrientIntakeOverview:
type: object
properties:
windowDays:
type: integer
minimum: 1
maximum: 9007199254740991
nutrients:
type: array
items:
type: object
properties:
nutrient:
$ref: "#/components/schemas/NutrientCode"
unit:
type: string
latestDay:
type: string
latestAmount:
type: number
daysWithData:
type: integer
minimum: 1
maximum: 9007199254740991
required:
- nutrient
- unit
- latestDay
- latestAmount
- daysWithData
additionalProperties: false
required:
- windowDays
- nutrients
additionalProperties: false
description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying
data inside the window. Nutrients without data in the window are omitted."
MedicationScheduleOutput:
type: object
properties:
+38 -1
View File
@@ -33,7 +33,9 @@ HealthLog supports two key-provisioning modes that coexist:
`AppSettings.adminAiKeyEncrypted`. Users who have not configured
their own key fall back to the admin key. This is the last entry
in the default provider chain so it acts as a safety net rather
than the default route.
than the default route. Since v1.28.14 the operator can also share
a subscription-based account that users opt into — see
[Admin-shared subscription access](#admin-shared-subscription-access).
Single-user instances usually pick one mode and stop there.
Multi-user instances tend to run BYOK with the admin-shared key as a
@@ -209,6 +211,41 @@ documented in `docs/codex-protocol-spec.md` for anyone who wants to
audit the request shape; users who just want the integration to
work do not need to read it.
## Admin-shared subscription access
Besides the shared API key, the operator can connect a single
subscription-based AI account for the whole instance — an
operator-shared subscription provider that users without any
personal AI setup can opt into.
- **Admin side.** Under **Admin → AI** the operator runs the same
device-code OAuth flow as the per-user connection
(`POST /api/admin/central-codex/device-start` +
`device-poll`). The tokens land AES-256-GCM-encrypted in the
`AppSettings` singleton and are never returned by any endpoint —
status and disconnect go through `GET` / `DELETE
/api/admin/central-codex`, which sit behind the cookie-only
`requireAdmin()` boundary.
- **User side.** Each user opts in individually in `/settings/ai`
(`PATCH /api/auth/me/use-central-codex`, body
`{ "useCentralCodex": boolean }`). The opt-in is off by default.
- **Chain semantics.** The shared connection is the internal
`admin-codex` chain entry. It is never part of the persisted chain
and never offered in the chain editor — it is appended **last** to
the resolved chain, and only when the user opted in AND the
operator has connected the account
(`src/lib/ai/provider-chain.ts`). A hand-crafted persisted entry
resolves to nothing.
- **Cost.** Generations through the shared connection run under the
operator's account, so usage and rate limits land on the operator,
not the user.
The settings UI states this plainly: the shared access is a single
signed-in AI account the operator connected for everyone on the
server — not the user's own key. Users who want their data to reach
only a provider under their own contract should configure a personal
provider instead; their own entries always resolve first.
## Privacy stance
- **OpenAI** and **Anthropic** see the prompt + structured snapshot
+20 -17
View File
@@ -5,10 +5,10 @@
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
at [dev.fitbit.com](https://dev.fitbit.com/apps/new)** — **not** a Google Cloud
at [dev.fitbit.com](https://dev.fitbit.com/)** — **not** a Google Cloud
OAuth client. Like WHOOP, credentials are brought per user: each user (or the
operator on their behalf) registers their own Fitbit app and pastes the client
id/secret into Settings. The internal integration key is `fitbit`.
operator on their behalf) supplies their own Fitbit app's client id/secret in
Settings. The internal integration key is `fitbit`.
> **Common mistake:** the field wants a **Fitbit** Client ID (a short numeric
> id from dev.fitbit.com), **not** a Google Cloud client id ending in
@@ -16,30 +16,33 @@ id/secret into Settings. The internal integration key is `fitbit`.
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
> and Google Cloud are separate client registries.
> **Heads-up — September 2026 sunset.** Google is retiring the classic Fitbit
> Web API in **September 2026** and moving to the Google Health API behind Google
> sign-in. That replacement currently requires Google Restricted-scope brand
> verification plus an annual third-party CASA security assessment, which does
> not fit a self-hosted bring-your-own-credentials model, so the path beyond the
> sunset is still being worked out. Until then the setup below is the supported
> way to connect Fitbit.
> **Heads-up — new registrations are closed.** Fitbit has **discontinued
> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is
> disabled for new applications, and the classic Fitbit Web API sunsets in
> **September 2026**. If you already hold a registered Fitbit app, it keeps
> working until the sunset and the setup below still applies. If you are
> connecting Fitbit data for the first time, use the
> [Google Health integration](google-health.md) instead — Fitbit devices sync
> into Google's health stack, which HealthLog reads directly.
## 1. Register a Fitbit developer app
## 1. Use an existing Fitbit developer app
1. Sign in at <https://dev.fitbit.com/apps/new> and register a new application.
2. **OAuth 2.0 Application Type:**
Registration of new apps is no longer possible (see the note above). For an
app you registered before the shutdown, these are the settings that matter:
1. **OAuth 2.0 Application Type:**
- **Personal** — the app only ever sees the **owner's own** Fitbit account,
and gets intraday (per-minute) heart-rate/steps automatically. This is the
right choice for a single-person self-host.
- **Server** (or **Client**) — needed when the instance connects **other
people's** accounts. Intraday/heart-rate access is then granted case by
case via Fitbit's Intraday Data Access request form.
3. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
2. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
— absolute, **HTTPS**, and matching the `redirect_uri` HealthLog sends (see
the redirect-URI note below). One entry per environment you connect from.
4. **Scopes:** tick the ones HealthLog requests (next section).
5. Save. Fitbit issues a **Client ID** (short, numeric) and a **Client Secret**
— keep the secret out of any chat log.
3. **Scopes:** the ones HealthLog requests (next section).
4. The app's settings page shows the **Client ID** (short, numeric) and the
**Client Secret** — keep the secret out of any chat log.
## 2. Scopes
+4 -2
View File
@@ -50,8 +50,10 @@ Click **Connect WHOOP**. The flow:
2. The user signs in on WHOOP and grants the requested scopes.
3. WHOOP redirects back to `/api/whoop/callback` with an auth code.
4. HealthLog exchanges the code for an access + refresh token, stores
them encrypted, registers the webhook, and triggers an initial
sync of recent history.
them encrypted, and triggers an initial sync of recent history.
(The webhook is **not** registered per user — it is configured once
at app level in the WHOOP developer console; see the env-var
section below.)
5. Subsequent syncs run on every webhook delivery plus a safety-net
cron pull.
+20 -17
View File
@@ -38,20 +38,25 @@ arrive via the sleep series endpoint.
needed — `user.metrics` covers weight / BP / heart-list / sleep /
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
workout-equivalent stream for steps / energy / distance / floors.
See `src/lib/withings/client.ts:42` (`WITHINGS_OAUTH_SCOPE`).
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
4. Save the application. Withings will issue a **Client ID** and a
**Client Secret** — keep the secret out of any chat log; it grants
read access to every linked account's full health history.
## 2. Wire the credentials into HealthLog
## 2. Wire the credentials in Settings → Integrations
Paste the client ID and secret into `.env`:
Withings credentials are stored per user, not in `.env`. Sign in as
the target user, open **Settings → Integrations → Withings**, and
paste the client ID and secret. They are encrypted AES-256-GCM at
rest (`src/lib/withings/credentials.ts`). On a shared instance every
user brings their own Withings app credentials; there is no
server-level client-id/secret fallback.
Two server-level env vars remain:
```env
WITHINGS_CLIENT_ID="your-client-id"
WITHINGS_CLIENT_SECRET="your-client-secret"
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
WITHINGS_WEBHOOK_SECRET="$(openssl rand -hex 32)"
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
```
`WITHINGS_WEBHOOK_SECRET` is the path-segment token the webhook
@@ -60,10 +65,9 @@ that fits in a URL path segment works, but a 64-character hex string
keeps it indistinguishable from random. Restart the `app` container
after saving.
Per-user override is also possible: the Settings UI accepts a
client-ID/secret pair scoped to one user, useful if you want a
shared instance where every user brings their own Withings app
credentials. Server-level env vars cover the default case.
`WITHINGS_REDIRECT_URI` is optional — when unset the callback URL
defaults to `${NEXT_PUBLIC_APP_URL}/api/withings/callback`. Set it
only when the OAuth callback must differ from that default.
## 3. Set the webhook URL
@@ -90,9 +94,8 @@ callback. The portal only owns the OAuth callback URL.
## 4. Link from the Settings page
With env vars in place and the app restarted, sign in as the target
user and open `/settings/integrations/withings`. Click **Connect
Withings**. The flow:
With the credentials saved in Settings, open
`/settings/integrations/withings` and click **Connect Withings**. The flow:
1. The Connect button redirects to Withings' `oauth2/authorize`.
2. The user signs in on Withings and grants the requested scopes.
@@ -133,10 +136,10 @@ Override per-user via the Sources section of `/settings/thresholds`.
### The Connect button errors out
- **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID`
or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both
are set in the container's runtime environment (`docker compose
exec app env | grep WITHINGS`).
- **`Withings credentials not configured`** — the signed-in user has
not saved a client ID + secret under **Settings → Integrations →
Withings**. Credentials live per user in the database, not in
`.env` — paste the pair from the developer portal and retry.
- **`Callback URI mismatch`** — the URL HealthLog sends to Withings
must match an entry in the developer-portal app's callback list.
Add the public URL exactly, including the `https://` scheme and
+14 -8
View File
@@ -68,20 +68,26 @@ The credentials are never returned.
## Restore
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
and run:
and run the restore script with the same backup credentials and
encryption key the backup was written under — a freshly generated
`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object:
```bash
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups \
BACKUP_S3_ACCESS_KEY=... \
BACKUP_S3_SECRET_KEY=... \
BACKUP_S3_REGION=auto \
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32) \
pnpm tsx scripts/restore-backup.ts \
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups \
BACKUP_S3_ACCESS_KEY=... \
BACKUP_S3_SECRET_KEY=... \
BACKUP_S3_REGION=auto \
BACKUP_ENCRYPTION_KEY=<the key the backup was written under> \
pnpm dlx tsx scripts/restore-backup.ts \
2026-05-08/user-clx123.json.enc \
/tmp/restored.json
```
The production standalone image strips `tsx`, so a bare
`pnpm tsx scripts/...` fails inside the container — always invoke
one-shot scripts via `pnpm dlx tsx`.
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
+2 -2
View File
@@ -44,7 +44,7 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
3. **Run the rotation script** to re-encrypt every existing encrypted column
under the new active key:
```
pnpm tsx scripts/rotate-encryption-key.ts v2
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
```
The script is idempotent — running it again is a no-op for rows already
prefixed with `v2.`. It rotates User, WithingsConnection, AppSettings,
@@ -69,7 +69,7 @@ script reports zero `v2.`-prefixed rows remaining.
```
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
ENCRYPTION_ACTIVE_KEY_ID="v3"
pnpm tsx scripts/rotate-encryption-key.ts v3
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
ENCRYPTION_KEYS='{"v3":"<new>"}'
```
+65 -48
View File
@@ -1,26 +1,34 @@
# TLS leaf SPKI pin — coupling, alarm, and re-pin runbook
# TLS SPKI pinning — coupling, alarm, and re-pin runbook
The native client SPKI-pins the server's TLS **leaf** certificate. This is a
deliberate hardening choice on the client side, but it couples the client to
the exact leaf keypair the server serves: when that leaf rotates, a client
that was only ever shipped the old pin refuses to connect. This page explains
the coupling, the server-side alarm that watches for a rotation, and the
operator / release-owner steps when it fires.
The native client SPKI-pins the server's TLS chain at the **CA level**. It
ships a small pin set — currently the issuing intermediate plus the root —
and accepts a connection only when the served chain passes baseline X.509
evaluation AND at least one certificate anywhere in the chain matches a pin
(any-match). The leaf is deliberately **not** pinned, so routine leaf
renewals are a client no-op. The outage risk is a **chain change**: the
served chain stops terminating in the pinned CA keys — the server moves to a
different CA, or the CA rotates its intermediate / root keys. This page
explains the coupling, the server-side alarm that watches the served leaf,
and the operator / release-owner steps when it fires.
## The coupling
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of the
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of a
certificate's public key, not of the whole certificate. The native client
carries a set of these pins and rejects any TLS connection whose leaf public
key is not in the set.
carries a set of these pins (build-time config, ≥ 2 well-formed pins
enforced for release builds) and rejects any TLS connection whose validated
chain contains no pinned key. Hosts outside the client's pinned-host list
fall back to plain system-trust validation, which is the documented model
for self-hosted instances that ship their own chain.
The app host's certificate is issued by Google Trust Services (GTS) and
auto-renews roughly every 90 days. Each renewal mints a **new leaf keypair**,
so the SPKI pin changes on every renewal. Intermediate and root rolls have
the same effect on the leaf chain. Because the client pins the leaf, every
such change is a hard outage for any client that hasn't been shipped the new
pin — and there is otherwise no server-side signal that a client is pinning
the served leaf at all.
The app host's certificate auto-renews roughly every 90 days and each
renewal mints a new leaf keypair. Because the client pins the CA keys rather
than the leaf, those renewals pass without a client build. What DOES break
every pinned client is a chain that no longer terminates in a pinned key —
and there is no server-side signal that clients are pinning the served chain
at all. The alarm below exists to catch exactly that early: it watches the
served **leaf**, the most frequently rotating element, as the canary for any
chain movement.
## The alarm
@@ -46,13 +54,15 @@ successful probe returning a pin genuinely outside the known set.
### Baseline source: an env var, not auto-learning
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set. The baseline is
an env var on purpose, **not** a persisted "last-seen" row:
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set of **served
leaf** pins. It is the operator's explicit record of what the server is
expected to serve — distinct from the client's CA-level pin set, which lives
in the client build config. The baseline is an env var on purpose, **not** a
persisted "last-seen" row:
- The pinned client only trusts the pins it was shipped, so the operator must
derive the client's pin set from a single explicit source of truth. The env
var **is** that source — the same value goes into the client's pin set and
into `TLS_LEAF_SPKI_PINS`.
- An explicit baseline forces the operator to acknowledge every leaf
rotation and check the new chain against the client's pinned CA keys
before the alarm goes quiet.
- A persisted last-seen baseline would silently adopt the first rotated pin
and suppress the very alarm the pinned client needs.
- When the env var is unset, the monitor fails **loud, not open**: it logs
@@ -84,36 +94,43 @@ The variable is on the compose `environment:` whitelist and the
whether it is set. It is optional: without it the monitor still runs and logs
the served pin, but cannot alarm on a change.
## When the alarm fires — re-pin procedure
## When the alarm fires
When you see a `tls.pin.leaf_changed` alert / `system.tls.pin_changed` audit
row, the served leaf has rotated. The pinned client will stop connecting once
its shipped pin no longer matches a served leaf. Act inside the certificate's
remaining validity, with margin for app review and client roll-out — target
**≥ 11 days before the old pin's certificate expires** (the alert body and
audit row both carry the new leaf's `validTo`).
row, the served leaf has rotated. First determine which of two cases you are
in:
1. **Re-extract the new leaf pin** with the `openssl` pipeline above. It
should equal the `servedPin` in the alert.
2. **Dual-pin.** Add the new pin to the client's pin set **alongside** the old
one (do not replace it yet), and update `TLS_LEAF_SPKI_PINS` to the
comma-separated pair, e.g.
`TLS_LEAF_SPKI_PINS="<old-pin>,<new-pin>"`. Holding both pins means clients
on the old build keep working through the cutover and the server-side
alarm goes quiet because the served pin is now in the known set. Re-deploy
the server so the new env value takes effect.
1. **Routine renewal, same chain.** Inspect the new chain (the `openssl`
pipeline above, or the alert payload). If the issuing intermediate and
root are unchanged, the client's CA-level pins still match and no client
is at risk. Update `TLS_LEAF_SPKI_PINS` to the new served-leaf pin and
re-deploy so the alarm goes quiet. No client build.
2. **Chain change.** The new leaf chains through a different intermediate /
root (CA switch, or the CA rotated its keys). Every pinned client will
stop connecting once the old chain is no longer served — run the re-pin
procedure below inside the old chain's remaining validity, with margin
for app review and client roll-out (the alert body and audit row carry
the new leaf's `validTo`).
### Re-pin procedure (chain change)
1. **Extract the new chain's CA pins** (intermediate + root) with the
`openssl` pipeline / the client repo's SPKI-extraction script.
2. **Dual-pin.** Add the new CA pins to the client's pin set **alongside**
the old ones (do not replace them yet). Clients on the old build keep
working as long as the old chain is still served; the dual-pinned build
works against both chains. Update `TLS_LEAF_SPKI_PINS` to the currently
served leaf pin(s) so the server-side alarm reflects reality.
3. **Ship the client build** carrying the dual-pin set to TestFlight (and
onward to release). Give it long enough to roll out to the installed base
before the next renewal.
4. **Retire the old pin** once the old leaf is no longer served and the
dual-pinned client build has reached the installed base: drop the old pin
from both the client pin set and `TLS_LEAF_SPKI_PINS`, leaving only the
current leaf pin.
onward to release). Give it long enough to roll out to the installed
base before the old chain disappears.
4. **Retire the old pins** once the old chain is no longer served and the
dual-pinned client build has reached the installed base.
Treat the dual-pin window as standing practice: always ship the **next** pin
before the current leaf rotates, so a renewal never catches a single-pinned
client. If GTS renews on a fixed cadence, pre-extracting and dual-pinning the
upcoming leaf ahead of the renewal turns every rotation into a no-op.
CA-level pins move rarely — intermediates and roots carry multi-year
lifetimes and rotations are announced well ahead. Track the pinned
certificates' expiry dates and pre-stage the successor pins before the
cutover, so a chain change never catches a single-chain client.
## Environment assumption
+32 -3
View File
@@ -24,15 +24,44 @@ deployed in three modes:
3. `docker compose up -d --build`.
Both containers connect to the same Postgres. pg-boss claims jobs
atomically via row-level locking, so it is safe to run multiple worker
replicas — they will share the load.
atomically via row-level locking (`FOR UPDATE SKIP LOCKED`), so
running multiple worker replicas is **safe** — no job runs twice —
but it doubles the DB load and muddies telemetry without adding
throughput a personal instance needs (`src/lib/process-type.ts`).
Run **one** worker container and scale the `web` containers instead.
## Apple Health import staging (shared volume required for a split)
The Apple Health `export.zip` import streams the upload to a staging
file on local disk (under the container's temp dir) and hands the
worker only the file **path**. In single-container mode
(`HEALTHLOG_PROCESS_TYPE=all`, the default) the web handler and the
worker share the same filesystem, so the handoff just works.
In a web/worker split — or any topology where the container that
accepts the upload is not the one that runs the job (a `web` +
`worker` split, extra `all` replicas, or old+new containers coexisting
during a rolling deploy) — the worker cannot see the web container's
staging file. The import then fails with a staging-file-missing error
("the import staging file is not visible to the worker"). Two things to
know:
- A pure `web` container has no pg-boss handle, so an import kicked off
there currently returns `503 Background worker is not running`. Run
imports from a container that also runs the worker until send-only
boss support lands.
- If you run web and worker as separate containers, they **must share
the import staging directory** — mount the same named volume at the
temp path on both — or run imports in single-container (`all`) mode.
Without a shared staging volume, imports in split mode cannot
complete.
## Healthchecks
- `app` (web) healthcheck: `wget /api/version` every 30s.
- `app-worker` does not expose a port; its liveness is reported into
the `worker_status` table on every reminder pass and surfaced through
`/api/admin/worker-status`.
`/api/admin/status`.
The web container does NOT depend on the worker, and vice versa, so
neither container's startup can deadlock the other. The shared
+150
View File
@@ -0,0 +1,150 @@
# SSO login (OIDC)
HealthLog can delegate sign-in to a self-hosted OpenID Connect identity
provider — Authentik, Keycloak, Authelia, Pocket-ID, or anything else
that serves a standard discovery document. The flow is the OIDC
authorization-code flow with PKCE against a single, env-configured
provider; by default it is **additive** (the SSO button appears next to
password and passkey login), with an explicit opt-in to make it the only
way in.
## Setup
1. Register a **confidential** OIDC client at your IdP with the redirect
URI
```
${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback
```
The comparison at the IdP is byte-exact: scheme, host, port, and path
all matter. `https://healthlog.example.com/api/auth/oidc/callback` and
`http://healthlog.example.com/api/auth/oidc/callback` are different
URIs, and a trailing slash breaks the match.
2. Set the three provider vars (all three together enable the login-page
button; anything less keeps SSO fully disabled):
```env
OIDC_ISSUER_URL="https://idp.example.com"
OIDC_CLIENT_ID="healthlog"
OIDC_CLIENT_SECRET="..."
```
The issuer must serve
`<issuer>/.well-known/openid-configuration`. Prefer HTTPS — the
authorization code and the token response carrying the ID token cross
this connection. Plain HTTP is defensible only on a fully trusted
private network (IdP and app on the same box, a Tailscale mesh, a
VPN); a LAN-only issuer is explicitly supported.
3. Optional tuning:
```env
OIDC_SCOPES="openid email profile" # default shown
OIDC_BUTTON_LABEL="Single Sign-On" # default shown
OIDC_ONLY="false" # see below before changing
```
4. Remember the compose env whitelist: every `OIDC_*` var must appear
under `environment:` in `docker-compose.yml` (the shipped file lists
them) — vars not on the whitelist never reach the container even when
`.env` has them.
`pnpm check-env` knows all six vars and flags a partially configured
provider group.
## Per-IdP notes
Client registration essentials only — the IdP's own docs cover the rest.
- **Authentik.** Create a _Provider_ of type OAuth2/OpenID (confidential
client), bind it to an _Application_, and use the provider's issuer URL
(shaped like `https://authentik.example.com/application/o/<app-slug>/`
— the trailing slash is part of Authentik's issuer; the discovery
check tolerates the difference). Signing key: any RSA/EC key; the
default RS256 works.
- **Keycloak.** Create a client with _Client authentication_ ON and the
_Standard flow_ enabled. The issuer is
`https://keycloak.example.com/realms/<realm>`. Keep the default RS256
signature algorithm; do not switch the client to `HS256` — HealthLog
rejects HMAC-signed ID tokens.
- **Authelia.** Define a client under `identity_providers.oidc.clients`
with `token_endpoint_auth_method: client_secret_post` (HealthLog sends
the secret in the POST body) and the redirect URI above. The issuer is
Authelia's root URL.
- **Pocket-ID.** Add an OIDC client, copy the generated ID and secret,
and use the Pocket-ID base URL as the issuer. Pocket-ID authenticates
users by passkey at the IdP; to HealthLog it is a provider like any
other.
## Security model
- **Identity is pinned, not guessed.** The first OIDC sign-in either
provisions a fresh account or links to an existing account matched by
**verified** email (`email_verified: true` — an IdP that does not
assert verification is rejected). Either way the account is stamped
with the provider's `(issuer, sub)` pair, and every later login
matches on that pair alone. An email change at the IdP updates the
displayed email; it can never re-point the login at a different
account. An email that matches an account already pinned to a
_different_ identity is rejected and audited.
- **SSO sessions never satisfy step-up.** Destructive actions (disabling
MFA, rotating encryption keys, encrypted export, account deletion)
require a _fresh_ second factor confirmed by HealthLog itself. An OIDC
login is a delegated factor — whatever the IdP checked, this app did
not see it — so an SSO session always has to confirm a native factor
(or the password path) when it hits one of those gates.
- **Accounts with native 2FA keep it.** If the account has a confirmed
TOTP secret or a registered security key, an OIDC login does not
complete on its own: after the IdP round-trip the user lands in the
same second-factor step password login uses. SSO never downgrades an
account's own MFA.
- **MFA belongs at the IdP.** For SSO-provisioned accounts, enforce
MFA in the IdP's policy engine — that is the point of delegating
authentication. HealthLog-side 2FA remains available on top for the
step-up gates above.
- **Linking is once.** There is no unlink/relink flow in this cut; the
pin is deliberately sticky. Moving an account to a new IdP identity is
an operator decision (a manual `users.oidc_issuer` / `users.oidc_sub`
update), not something a login can do.
## OIDC_ONLY
`OIDC_ONLY="true"` disables password and passkey login **server-side**
(the routes return 403; hiding the buttons is not the boundary).
Consequences to accept before turning it on:
- **The native iOS app cannot sign in.** It authenticates via
password/passkey only; there is no native SSO flow yet. Do not enable
`OIDC_ONLY` on a deployment with active iOS users.
- **Existing sessions survive.** The flag gates new sign-ins, not live
sessions — nobody is thrown out at flip time, including
password-authenticated sessions.
- **Self-disabling on half-config.** The flag only takes effect while
the three provider vars are fully set, so a typo in the provider group
can never lock every user out.
### Break-glass (IdP down or misconfigured)
1. Unset `OIDC_ONLY` (or set it to `"false"`) in `.env`, then
`docker compose up -d`. Password and passkey login are live again
immediately.
2. Accounts that were **provisioned** through SSO have no password. Set
one from the host, per [docs/ops/password-reset.md](../ops/password-reset.md):
```sh
docker compose exec app node scripts/reset-password.mjs <username-or-email>
```
## Troubleshooting
| Symptom | Cause / fix |
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
+11 -19
View File
@@ -357,26 +357,18 @@ test.describe("document vault — AI assist + content search", () => {
page,
}) => {
// CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it).
// No mocks: the real list GET threads the provenance through. v1.28.22 —
// the marker lives on the vault CARD's meta row (an inline glyph beside
// date · size), not in the sheet. Surface the card through search (the
// grid is virtualized, so a targeted card must be brought into view the
// way a user would).
await page.goto("/documents");
await expect(
page.getByRole("heading", { name: "Documents" }),
).toBeVisible();
await page
.getByRole("searchbox", { name: "Search documents" })
.fill(CONTENT_BODY_WORD);
await expect(
page.getByRole("button", { name: "Open Radiology note" }),
).toBeVisible({ timeout: 10_000 });
// No mocks: the real detail GET threads the provenance through. v1.28.38 —
// the vault card no longer carries the AI-read glyph (it looked wrong on
// the card face); the provenance now lives as a calm muted meta line in the
// document DETAIL view. Deep-link straight to the document so the sheet
// opens deterministically (the vault grid is virtualized).
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const marker = page.locator(
`[data-document-id="${CONTENT_DOC_ID}"] [data-slot="document-card-ai-read"]`,
);
await expect(marker.first()).toBeVisible();
const marker = sheet.locator('[data-slot="document-detail-ai-read"]');
await expect(marker).toBeVisible();
await expect(marker).toHaveText("Read by AI");
});
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
+108
View File
@@ -0,0 +1,108 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Issue #490 — client-rendered timestamps follow the PROFILE timezone.
*
* `/api/auth/me` is mocked with an `Asia/Manila` profile; the sessions
* card on /settings/privacy renders `lastActiveAt` through
* `useFormatters()`, which reads the localStorage timezone mirror that
* `fetchMe` fills from the mocked payload. A fixed UTC instant must
* therefore paint the MANILA wall clock (14:30), never the Berlin
* fallback (08:30) and never the browser zone (the Playwright context is
* pinned to Europe/Berlin precisely so this assertion is meaningful).
*
* Asserts against the stable `data-slot="security-session-row"` hook,
* not viewport-dependent text.
*/
// 06:30 UTC = 14:30 Asia/Manila (+8, no DST) = 08:30 Europe/Berlin (CEST).
const LAST_ACTIVE_UTC = "2026-07-14T06:30:00.000Z";
test.describe("profile-timezone display (#490)", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("sessions card renders lastActiveAt in the Manila profile zone", async ({
page,
}) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
id: "user_e2e",
username: "e2e-tester",
email: "e2e@healthlog.test",
role: "USER",
heightCm: 180,
dateOfBirth: "1990-01-01",
gender: "MALE",
timezone: "Asia/Manila",
timeFormat: "H24",
dateFormat: "AUTO",
onboardingCompletedAt: "2025-01-01T00:00:00.000Z",
onboardingTourCompleted: true,
gravatarUrl: null,
glucoseUnit: "mg/dL",
},
error: null,
}),
}),
);
await page.route("**/api/auth/me/sessions", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
sessions: [
{
id: "sess_e2e_current",
device: "Chrome on macOS",
ipMasked: "192.168.1.xxx",
location: null,
lastActiveAt: LAST_ACTIVE_UTC,
createdAt: LAST_ACTIVE_UTC,
isCurrent: true,
},
],
},
error: null,
}),
}),
);
await page.goto("/settings/privacy", { waitUntil: "domcontentloaded" });
// The sessions card is collapsed by default — expand it first. The
// toggle's onClick can be dropped if Playwright clicks before React has
// hydrated the handler, leaving the region `hidden` for the whole wait.
// Retry the click until `aria-expanded` actually flips.
const toggle = page.locator(
'[data-slot="settings-security-sessions-toggle"]',
);
await expect(toggle).toBeVisible({ timeout: 10_000 });
await expect(async () => {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true", {
timeout: 2_000,
});
}).toPass({ timeout: 15_000 });
const row = page.locator('[data-slot="security-session-row"]').first();
await expect(row).toBeVisible({ timeout: 10_000 });
// Manila wall clock (H24). The mirror write from the mocked /me may
// land after the first paint of the sessions row — the poll absorbs
// the re-render.
await expect(row).toContainText("14:30", { timeout: 10_000 });
// Never the Berlin fallback for this instant…
await expect(row).not.toContainText("08:30");
// …and never a 12-hour rendering (the H24 preference rides the same
// mirror pipeline).
await expect(row).not.toContainText("PM");
});
});
+66 -7
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
"privacyPolicy": "Datenschutzerklärung",
"passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes.",
"oidc": {
"signInDefault": "Mit SSO anmelden",
"errorDenied": "Die Anmeldung wurde abgebrochen.",
"errorNoEmail": "Dein Identitätsanbieter hat keine bestätigte E-Mail-Adresse übermittelt.",
"errorEmailUnverified": "Dein Identitätsanbieter hat deine E-Mail-Adresse nicht als bestätigt markiert.",
"errorIdentityConflict": "Diese E-Mail-Adresse gehört zu einem Konto, das bereits mit einer anderen SSO-Identität verknüpft ist.",
"errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.",
"errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut."
},
"mfa": {
"title": "Zwei-Faktor-Authentifizierung",
"totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
@@ -4238,7 +4247,16 @@
"other": "Sonstige",
"unknown": "Unbekannt"
},
"subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern."
"subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern.",
"nutrients": {
"title": "Synchronisierte Nährstoffzufuhr",
"description": "Tagessummen aus Apple Health der letzten {days} Tage. Nur lesend — die Quell-App besitzt die Werte.",
"empty": "Noch keine Nährstoffdaten synchronisiert.",
"latest": "{amount} {unit} am {date}",
"daysWithData": "{count} von {days} Tagen",
"moduleHint": "Die Synchronisierung steuert das Modul „Nährstoffzufuhr“.",
"moduleHintLink": "Module verwalten"
}
},
"ai": {
"title": "KI-Anbieter",
@@ -7160,8 +7178,11 @@
"TOTAL_BODY_WATER": "Körperwasser",
"VISCERAL_FAT": "Viszeralfett",
"BODY_MASS_INDEX": "Body-Mass-Index (BMI)",
"WAIST_CIRCUMFERENCE": "Taillenumfang",
"PHQ9_SCORE": "PHQ-9 · Stimmung",
"GAD7_SCORE": "GAD-7 · Sorgen"
"GAD7_SCORE": "GAD-7 · Sorgen",
"WHO5_SCORE": "WHO-5 · Wohlbefinden",
"SCI_SCORE": "SCI · Schlaf"
},
"typeGroups": {
"vitals": "Vitalwerte",
@@ -7570,6 +7591,10 @@
"mentalHealth": {
"label": "Seelisches Wohlbefinden",
"description": "Freiwillige PHQ-9- und GAD-7-Selbsttests ergänzend zum Stimmungstagebuch. Standardmäßig aus; Antworten sind verschlüsselt und bleiben aus dem KI-Coach heraus."
},
"nutrients": {
"label": "Nährstoffzufuhr",
"description": "Speichert tägliche Mikronährstoff-Summen (Vitamine, Mineralstoffe, Wasser, Koffein) aus Apple Health. Standardmäßig aus."
}
},
"illness": {
@@ -7726,12 +7751,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Sauerstoffsättigung",
"BODY_TEMPERATURE": "Körpertemperatur",
"SKIN_TEMPERATURE": "Hauttemperatur",
"RESPIRATORY_RATE": "Atemfrequenz",
"BLOOD_PRESSURE_SYS": "Systolischer Blutdruck",
"BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Funktionelle Beeinträchtigung"
},
"listLoadError": "Episoden konnten nicht geladen werden."
},
@@ -8280,7 +8305,9 @@
"deleted": "{count} Dokument(e) gelöscht",
"restored": "{count} Dokument(e) wiederhergestellt",
"partialFailure": "{failed} von {total} konnten nicht aktualisiert werden",
"failed": "Die Aktion konnte nicht ausgeführt werden"
"failed": "Die Aktion konnte nicht ausgeführt werden",
"share": "Teilen",
"shareTooMany": "Ein Freigabelink fasst höchstens {max} Dokumente. Wähle einige ab und versuche es erneut."
},
"episodeCard": {
"title": "Dokumente",
@@ -8344,6 +8371,8 @@
},
"share": {
"title": "Dieses Dokument teilen",
"multiTitle": "Diese Dokumente teilen",
"multiLabel": "{count} Dokumente",
"description": "Erstelle einen schreibgeschützten Ärzte-Link mit diesem angehängten Dokument. Link und Passphrase erscheinen nur einmal."
},
"chat": {
@@ -8363,8 +8392,8 @@
"errorCredential": "Der KI-Anbieter muss in den Einstellungen neu verbunden werden.",
"errorConsent": "Aktiviere die KI-Zustimmung in den Einstellungen, um über dieses Dokument zu chatten.",
"errorNetwork": "Du scheinst offline zu sein. Prüfe deine Verbindung und versuche es erneut.",
"openAria": "Über dieses Dokument chatten",
"openButton": "Zu diesem Dokument fragen",
"openAria": "Coach über dieses Dokument fragen",
"openButton": "Coach fragen",
"scopeBadge": "Chat über: {title}"
}
},
@@ -8452,5 +8481,35 @@
"valuesDescription": "Alle erfassten Werte dieser Metrik.",
"backToMetric": "Zurück zu {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Vitamin A",
"thiamin": "Thiamin (B1)",
"riboflavin": "Riboflavin (B2)",
"niacin": "Niacin (B3)",
"pantothenic_acid": "Pantothensäure (B5)",
"vitamin_b6": "Vitamin B6",
"biotin": "Biotin (B7)",
"folate": "Folat (B9)",
"vitamin_b12": "Vitamin B12",
"vitamin_c": "Vitamin C",
"vitamin_d": "Vitamin D",
"vitamin_e": "Vitamin E",
"vitamin_k": "Vitamin K",
"calcium": "Calcium",
"iron": "Eisen",
"magnesium": "Magnesium",
"phosphorus": "Phosphor",
"zinc": "Zink",
"copper": "Kupfer",
"manganese": "Mangan",
"selenium": "Selen",
"chromium": "Chrom",
"molybdenum": "Molybdän",
"iodine": "Jod",
"water": "Wasser",
"caffeine": "Koffein"
}
}
}
+66 -7
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "Password must be at least {minLength} characters long.",
"privacyPolicy": "Privacy policy",
"passwordBreached": "This password has appeared in known data breaches. Please choose a different one.",
"oidc": {
"signInDefault": "Sign in with SSO",
"errorDenied": "Sign-in was cancelled.",
"errorNoEmail": "Your identity provider did not share a verified email address.",
"errorEmailUnverified": "Your identity provider did not mark your email address as verified.",
"errorIdentityConflict": "That email address belongs to an account that is already linked to a different SSO identity.",
"errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.",
"errorFailed": "Single sign-on failed. Please try again."
},
"mfa": {
"title": "Two-factor authentication",
"totpHint": "Enter the 6-digit code from your authenticator app.",
@@ -4238,7 +4247,16 @@
"other": "Other",
"unknown": "Unknown"
},
"subtitle": "Decide which source wins when two report the same metric."
"subtitle": "Decide which source wins when two report the same metric.",
"nutrients": {
"title": "Synced nutrient intake",
"description": "Daily totals synced from Apple Health over the last {days} days. Read-only — the source app owns the numbers.",
"empty": "No nutrient data has been synced yet.",
"latest": "{amount} {unit} on {date}",
"daysWithData": "{count} of {days} days",
"moduleHint": "Syncing is controlled by the Nutrient intake module.",
"moduleHintLink": "Manage modules"
}
},
"ai": {
"title": "AI provider",
@@ -7160,8 +7178,11 @@
"TOTAL_BODY_WATER": "Body water",
"VISCERAL_FAT": "Visceral fat",
"BODY_MASS_INDEX": "Body mass index (BMI)",
"WAIST_CIRCUMFERENCE": "Waist circumference",
"PHQ9_SCORE": "PHQ-9 · mood",
"GAD7_SCORE": "GAD-7 · worry"
"GAD7_SCORE": "GAD-7 · worry",
"WHO5_SCORE": "WHO-5 · wellbeing",
"SCI_SCORE": "SCI · sleep"
},
"typeGroups": {
"vitals": "Vitals",
@@ -7570,6 +7591,10 @@
"mentalHealth": {
"label": "Mental wellbeing",
"description": "Opt-in PHQ-9 and GAD-7 screeners beside mood tracking. Off by default; answers are encrypted and kept out of the AI Coach."
},
"nutrients": {
"label": "Nutrient intake",
"description": "Store daily micronutrient totals (vitamins, minerals, water, caffeine) synced from Apple Health. Off by default."
}
},
"illness": {
@@ -7726,12 +7751,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Oxygen saturation",
"BODY_TEMPERATURE": "Body temperature",
"SKIN_TEMPERATURE": "Skin temperature",
"RESPIRATORY_RATE": "Respiratory rate",
"BLOOD_PRESSURE_SYS": "Systolic blood pressure",
"BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Functional impact"
},
"listLoadError": "Could not load your episodes."
},
@@ -8280,7 +8305,9 @@
"deleted": "{count} document(s) deleted",
"restored": "{count} document(s) restored",
"partialFailure": "{failed} of {total} could not be updated",
"failed": "The action could not be completed"
"failed": "The action could not be completed",
"share": "Share",
"shareTooMany": "A share link can hold at most {max} documents. Deselect some and try again."
},
"episodeCard": {
"title": "Documents",
@@ -8344,6 +8371,8 @@
},
"share": {
"title": "Share this document",
"multiTitle": "Share these documents",
"multiLabel": "{count} documents",
"description": "Create a read-only clinician link with this document attached. The link and its passphrase appear once."
},
"chat": {
@@ -8363,8 +8392,8 @@
"errorCredential": "The AI provider needs reconnecting in settings.",
"errorConsent": "Turn on AI consent in settings to chat about this document.",
"errorNetwork": "You appear to be offline. Check your connection and try again.",
"openAria": "Chat about this document",
"openButton": "Ask about this document",
"openAria": "Ask the Coach about this document",
"openButton": "Ask the Coach",
"scopeBadge": "Chatting about: {title}"
}
},
@@ -8452,5 +8481,35 @@
"valuesDescription": "Every logged value for this metric.",
"backToMetric": "Back to {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Vitamin A",
"thiamin": "Thiamin (B1)",
"riboflavin": "Riboflavin (B2)",
"niacin": "Niacin (B3)",
"pantothenic_acid": "Pantothenic acid (B5)",
"vitamin_b6": "Vitamin B6",
"biotin": "Biotin (B7)",
"folate": "Folate (B9)",
"vitamin_b12": "Vitamin B12",
"vitamin_c": "Vitamin C",
"vitamin_d": "Vitamin D",
"vitamin_e": "Vitamin E",
"vitamin_k": "Vitamin K",
"calcium": "Calcium",
"iron": "Iron",
"magnesium": "Magnesium",
"phosphorus": "Phosphorus",
"zinc": "Zinc",
"copper": "Copper",
"manganese": "Manganese",
"selenium": "Selenium",
"chromium": "Chromium",
"molybdenum": "Molybdenum",
"iodine": "Iodine",
"water": "Water",
"caffeine": "Caffeine"
}
}
}
+66 -7
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "La contraseña debe tener al menos {minLength} caracteres.",
"privacyPolicy": "Política de privacidad",
"passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra.",
"oidc": {
"signInDefault": "Iniciar sesión con SSO",
"errorDenied": "El inicio de sesión se canceló.",
"errorNoEmail": "Tu proveedor de identidad no compartió un correo electrónico verificado.",
"errorEmailUnverified": "Tu proveedor de identidad no marcó tu correo electrónico como verificado.",
"errorIdentityConflict": "Ese correo electrónico pertenece a una cuenta que ya está vinculada a otra identidad SSO.",
"errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.",
"errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo."
},
"mfa": {
"title": "Autenticación de dos factores",
"totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.",
@@ -4238,7 +4247,16 @@
"other": "Otro",
"unknown": "Desconocido"
},
"subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica."
"subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica.",
"nutrients": {
"title": "Ingesta de nutrientes sincronizada",
"description": "Totales diarios sincronizados desde Apple Health en los últimos {days} días. Solo lectura: la app de origen es la dueña de los valores.",
"empty": "Todavía no se han sincronizado datos de nutrientes.",
"latest": "{amount} {unit} el {date}",
"daysWithData": "{count} de {days} días",
"moduleHint": "La sincronización se controla con el módulo de ingesta de nutrientes.",
"moduleHintLink": "Gestionar módulos"
}
},
"ai": {
"title": "Proveedor de IA",
@@ -7160,8 +7178,11 @@
"TOTAL_BODY_WATER": "Agua corporal",
"VISCERAL_FAT": "Grasa visceral",
"BODY_MASS_INDEX": "Índice de masa corporal (IMC)",
"WAIST_CIRCUMFERENCE": "Perímetro de cintura",
"PHQ9_SCORE": "PHQ-9 · ánimo",
"GAD7_SCORE": "GAD-7 · preocupación"
"GAD7_SCORE": "GAD-7 · preocupación",
"WHO5_SCORE": "WHO-5 · bienestar",
"SCI_SCORE": "SCI · sueño"
},
"typeGroups": {
"vitals": "Constantes vitales",
@@ -7570,6 +7591,10 @@
"mentalHealth": {
"label": "Bienestar mental",
"description": "Cuestionarios PHQ-9 y GAD-7 opcionales, junto al registro de ánimo. Desactivado por defecto; las respuestas se cifran y se mantienen fuera del Coach de IA."
},
"nutrients": {
"label": "Ingesta de nutrientes",
"description": "Guarda los totales diarios de micronutrientes (vitaminas, minerales, agua, cafeína) sincronizados desde Apple Health. Desactivado por defecto."
}
},
"illness": {
@@ -7726,12 +7751,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Saturación de oxígeno",
"BODY_TEMPERATURE": "Temperatura corporal",
"SKIN_TEMPERATURE": "Temperatura de la piel",
"RESPIRATORY_RATE": "Frecuencia respiratoria",
"BLOOD_PRESSURE_SYS": "Presión arterial sistólica",
"BLOOD_PRESSURE_DIA": "Presión arterial diastólica",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Impacto funcional"
},
"listLoadError": "No se pudieron cargar tus episodios."
},
@@ -8280,7 +8305,9 @@
"deleted": "{count} documento(s) eliminados",
"restored": "{count} documento(s) restaurados",
"partialFailure": "No se pudieron actualizar {failed} de {total}",
"failed": "No se pudo completar la acción"
"failed": "No se pudo completar la acción",
"share": "Compartir",
"shareTooMany": "Un enlace para compartir admite como máximo {max} documentos. Deselecciona algunos e inténtalo de nuevo."
},
"episodeCard": {
"title": "Documentos",
@@ -8344,6 +8371,8 @@
},
"share": {
"title": "Compartir este documento",
"multiTitle": "Compartir estos documentos",
"multiLabel": "{count} documentos",
"description": "Crea un enlace de solo lectura para profesionales con este documento adjunto. El enlace y su frase de contraseña aparecen una sola vez."
},
"chat": {
@@ -8363,8 +8392,8 @@
"errorCredential": "Hay que volver a conectar el proveedor de IA en los ajustes.",
"errorConsent": "Activa el consentimiento de IA en los ajustes para chatear sobre este documento.",
"errorNetwork": "Parece que no tienes conexión. Comprueba tu red e inténtalo de nuevo.",
"openAria": "Chatea sobre este documento",
"openButton": "Pregunta sobre este documento",
"openAria": "Pregunta al Coach sobre este documento",
"openButton": "Preguntar al Coach",
"scopeBadge": "Chateando sobre: {title}"
}
},
@@ -8452,5 +8481,35 @@
"valuesDescription": "Todos los valores registrados de esta métrica.",
"backToMetric": "Volver a {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Vitamina A",
"thiamin": "Tiamina (B1)",
"riboflavin": "Riboflavina (B2)",
"niacin": "Niacina (B3)",
"pantothenic_acid": "Ácido pantoténico (B5)",
"vitamin_b6": "Vitamina B6",
"biotin": "Biotina (B7)",
"folate": "Folato (B9)",
"vitamin_b12": "Vitamina B12",
"vitamin_c": "Vitamina C",
"vitamin_d": "Vitamina D",
"vitamin_e": "Vitamina E",
"vitamin_k": "Vitamina K",
"calcium": "Calcio",
"iron": "Hierro",
"magnesium": "Magnesio",
"phosphorus": "Fósforo",
"zinc": "Zinc",
"copper": "Cobre",
"manganese": "Manganeso",
"selenium": "Selenio",
"chromium": "Cromo",
"molybdenum": "Molibdeno",
"iodine": "Yodo",
"water": "Agua",
"caffeine": "Cafeína"
}
}
}
+66 -7
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "Le mot de passe doit comporter au moins {minLength} caractères.",
"privacyPolicy": "Politique de confidentialité",
"passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre.",
"oidc": {
"signInDefault": "Se connecter avec le SSO",
"errorDenied": "La connexion a été annulée.",
"errorNoEmail": "Votre fournisseur d'identité n'a pas transmis d'adresse e-mail vérifiée.",
"errorEmailUnverified": "Votre fournisseur d'identité n'a pas marqué votre adresse e-mail comme vérifiée.",
"errorIdentityConflict": "Cette adresse e-mail appartient à un compte déjà lié à une autre identité SSO.",
"errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.",
"errorFailed": "L'authentification unique a échoué. Veuillez réessayer."
},
"mfa": {
"title": "Authentification à deux facteurs",
"totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.",
@@ -4238,7 +4247,16 @@
"other": "Autre",
"unknown": "Inconnu"
},
"subtitle": "Choisissez la source prioritaire en cas de doublon de mesure."
"subtitle": "Choisissez la source prioritaire en cas de doublon de mesure.",
"nutrients": {
"title": "Apports en nutriments synchronisés",
"description": "Totaux quotidiens synchronisés depuis Apple Health sur les {days} derniers jours. Lecture seule : l'app source reste propriétaire des valeurs.",
"empty": "Aucune donnée de nutriments synchronisée pour l'instant.",
"latest": "{amount} {unit} le {date}",
"daysWithData": "{count} jours sur {days}",
"moduleHint": "La synchronisation est contrôlée par le module Apports en nutriments.",
"moduleHintLink": "Gérer les modules"
}
},
"ai": {
"title": "Fournisseur d'IA",
@@ -7160,8 +7178,11 @@
"TOTAL_BODY_WATER": "Eau corporelle",
"VISCERAL_FAT": "Graisse viscérale",
"BODY_MASS_INDEX": "Indice de masse corporelle (IMC)",
"WAIST_CIRCUMFERENCE": "Tour de taille",
"PHQ9_SCORE": "PHQ-9 · humeur",
"GAD7_SCORE": "GAD-7 · inquiétude"
"GAD7_SCORE": "GAD-7 · inquiétude",
"WHO5_SCORE": "WHO-5 · bien-être",
"SCI_SCORE": "SCI · sommeil"
},
"typeGroups": {
"vitals": "Constantes vitales",
@@ -7570,6 +7591,10 @@
"mentalHealth": {
"label": "Bien-être mental",
"description": "Questionnaires PHQ-9 et GAD-7 facultatifs, en complément du suivi de l'humeur. Désactivé par défaut ; les réponses sont chiffrées et tenues à l'écart du Coach IA."
},
"nutrients": {
"label": "Apports en nutriments",
"description": "Enregistre les totaux quotidiens de micronutriments (vitamines, minéraux, eau, caféine) synchronisés depuis Apple Health. Désactivé par défaut."
}
},
"illness": {
@@ -7726,12 +7751,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Saturation en oxygène",
"BODY_TEMPERATURE": "Température corporelle",
"SKIN_TEMPERATURE": "Température cutanée",
"RESPIRATORY_RATE": "Fréquence respiratoire",
"BLOOD_PRESSURE_SYS": "Pression artérielle systolique",
"BLOOD_PRESSURE_DIA": "Pression artérielle diastolique",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Retentissement fonctionnel"
},
"listLoadError": "Impossible de charger vos épisodes."
},
@@ -8280,7 +8305,9 @@
"deleted": "{count} document(s) supprimés",
"restored": "{count} document(s) restaurés",
"partialFailure": "Impossible de mettre à jour {failed} sur {total}",
"failed": "L'action n'a pas pu être effectuée"
"failed": "L'action n'a pas pu être effectuée",
"share": "Partager",
"shareTooMany": "Un lien de partage contient au maximum {max} documents. Désélectionnez-en quelques-uns et réessayez."
},
"episodeCard": {
"title": "Documents",
@@ -8344,6 +8371,8 @@
},
"share": {
"title": "Partager ce document",
"multiTitle": "Partager ces documents",
"multiLabel": "{count} documents",
"description": "Créez un lien clinicien en lecture seule avec ce document joint. Le lien et sa phrase secrète n’apparaissent qu’une seule fois."
},
"chat": {
@@ -8363,8 +8392,8 @@
"errorCredential": "Le fournisseur d'IA doit être reconnecté dans les paramètres.",
"errorConsent": "Activez le consentement à l'IA dans les paramètres pour discuter de ce document.",
"errorNetwork": "Vous semblez hors ligne. Vérifiez votre connexion et réessayez.",
"openAria": "Discuter de ce document",
"openButton": "Poser une question sur ce document",
"openAria": "Interroger le Coach sur ce document",
"openButton": "Demander au Coach",
"scopeBadge": "Discussion sur : {title}"
}
},
@@ -8452,5 +8481,35 @@
"valuesDescription": "Toutes les valeurs enregistrées pour cette mesure.",
"backToMetric": "Retour à {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Vitamine A",
"thiamin": "Thiamine (B1)",
"riboflavin": "Riboflavine (B2)",
"niacin": "Niacine (B3)",
"pantothenic_acid": "Acide pantothénique (B5)",
"vitamin_b6": "Vitamine B6",
"biotin": "Biotine (B7)",
"folate": "Folates (B9)",
"vitamin_b12": "Vitamine B12",
"vitamin_c": "Vitamine C",
"vitamin_d": "Vitamine D",
"vitamin_e": "Vitamine E",
"vitamin_k": "Vitamine K",
"calcium": "Calcium",
"iron": "Fer",
"magnesium": "Magnésium",
"phosphorus": "Phosphore",
"zinc": "Zinc",
"copper": "Cuivre",
"manganese": "Manganèse",
"selenium": "Sélénium",
"chromium": "Chrome",
"molybdenum": "Molybdène",
"iodine": "Iode",
"water": "Eau",
"caffeine": "Caféine"
}
}
}
+66 -7
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "La password deve avere almeno {minLength} caratteri.",
"privacyPolicy": "Informativa sulla privacy",
"passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra.",
"oidc": {
"signInDefault": "Accedi con SSO",
"errorDenied": "L'accesso è stato annullato.",
"errorNoEmail": "Il tuo provider di identità non ha condiviso un indirizzo email verificato.",
"errorEmailUnverified": "Il tuo provider di identità non ha contrassegnato il tuo indirizzo email come verificato.",
"errorIdentityConflict": "Questo indirizzo email appartiene a un account già collegato a un'altra identità SSO.",
"errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.",
"errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova."
},
"mfa": {
"title": "Autenticazione a due fattori",
"totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.",
@@ -4238,7 +4247,16 @@
"other": "Altro",
"unknown": "Sconosciuto"
},
"subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica."
"subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica.",
"nutrients": {
"title": "Assunzione di nutrienti sincronizzata",
"description": "Totali giornalieri sincronizzati da Apple Health negli ultimi {days} giorni. Sola lettura: i valori appartengono all'app di origine.",
"empty": "Nessun dato sui nutrienti ancora sincronizzato.",
"latest": "{amount} {unit} il {date}",
"daysWithData": "{count} giorni su {days}",
"moduleHint": "La sincronizzazione è controllata dal modulo Assunzione di nutrienti.",
"moduleHintLink": "Gestisci moduli"
}
},
"ai": {
"title": "Provider IA",
@@ -7160,8 +7178,11 @@
"TOTAL_BODY_WATER": "Acqua corporea",
"VISCERAL_FAT": "Grasso viscerale",
"BODY_MASS_INDEX": "Indice di massa corporea (IMC)",
"WAIST_CIRCUMFERENCE": "Circonferenza vita",
"PHQ9_SCORE": "PHQ-9 · umore",
"GAD7_SCORE": "GAD-7 · preoccupazione"
"GAD7_SCORE": "GAD-7 · preoccupazione",
"WHO5_SCORE": "WHO-5 · benessere",
"SCI_SCORE": "SCI · sonno"
},
"typeGroups": {
"vitals": "Parametri vitali",
@@ -7570,6 +7591,10 @@
"mentalHealth": {
"label": "Benessere mentale",
"description": "Questionari PHQ-9 e GAD-7 facoltativi, accanto al diario dell'umore. Disattivato per impostazione predefinita; le risposte sono cifrate e tenute fuori dal Coach IA."
},
"nutrients": {
"label": "Assunzione di nutrienti",
"description": "Salva i totali giornalieri di micronutrienti (vitamine, minerali, acqua, caffeina) sincronizzati da Apple Health. Disattivato per impostazione predefinita."
}
},
"illness": {
@@ -7726,12 +7751,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Saturazione di ossigeno",
"BODY_TEMPERATURE": "Temperatura corporea",
"SKIN_TEMPERATURE": "Temperatura cutanea",
"RESPIRATORY_RATE": "Frequenza respiratoria",
"BLOOD_PRESSURE_SYS": "Pressione arteriosa sistolica",
"BLOOD_PRESSURE_DIA": "Pressione arteriosa diastolica",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Impatto funzionale"
},
"listLoadError": "Impossibile caricare gli episodi."
},
@@ -8280,7 +8305,9 @@
"deleted": "{count} documenti eliminati",
"restored": "{count} documenti ripristinati",
"partialFailure": "Impossibile aggiornare {failed} su {total}",
"failed": "Impossibile completare l'azione"
"failed": "Impossibile completare l'azione",
"share": "Condividi",
"shareTooMany": "Un link di condivisione contiene al massimo {max} documenti. Deseleziona alcuni documenti e riprova."
},
"episodeCard": {
"title": "Documenti",
@@ -8344,6 +8371,8 @@
},
"share": {
"title": "Condividi questo documento",
"multiTitle": "Condividi questi documenti",
"multiLabel": "{count} documenti",
"description": "Crea un link di sola lettura per il medico con questo documento allegato. Il link e la relativa passphrase compaiono una sola volta."
},
"chat": {
@@ -8363,8 +8392,8 @@
"errorCredential": "Il provider IA va ricollegato nelle impostazioni.",
"errorConsent": "Attiva il consenso all'IA nelle impostazioni per chattare su questo documento.",
"errorNetwork": "Sembri offline. Controlla la connessione e riprova.",
"openAria": "Chatta su questo documento",
"openButton": "Fai una domanda su questo documento",
"openAria": "Chiedi al Coach su questo documento",
"openButton": "Chiedi al Coach",
"scopeBadge": "Stai chattando su: {title}"
}
},
@@ -8452,5 +8481,35 @@
"valuesDescription": "Tutti i valori registrati per questa metrica.",
"backToMetric": "Torna a {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Vitamina A",
"thiamin": "Tiamina (B1)",
"riboflavin": "Riboflavina (B2)",
"niacin": "Niacina (B3)",
"pantothenic_acid": "Acido pantotenico (B5)",
"vitamin_b6": "Vitamina B6",
"biotin": "Biotina (B7)",
"folate": "Folati (B9)",
"vitamin_b12": "Vitamina B12",
"vitamin_c": "Vitamina C",
"vitamin_d": "Vitamina D",
"vitamin_e": "Vitamina E",
"vitamin_k": "Vitamina K",
"calcium": "Calcio",
"iron": "Ferro",
"magnesium": "Magnesio",
"phosphorus": "Fosforo",
"zinc": "Zinco",
"copper": "Rame",
"manganese": "Manganese",
"selenium": "Selenio",
"chromium": "Cromo",
"molybdenum": "Molibdeno",
"iodine": "Iodio",
"water": "Acqua",
"caffeine": "Caffeina"
}
}
}
+66 -7
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "Hasło musi mieć co najmniej {minLength} znaków.",
"privacyPolicy": "Polityka prywatności",
"passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne.",
"oidc": {
"signInDefault": "Zaloguj się przez SSO",
"errorDenied": "Logowanie zostało anulowane.",
"errorNoEmail": "Twój dostawca tożsamości nie przekazał zweryfikowanego adresu e-mail.",
"errorEmailUnverified": "Twój dostawca tożsamości nie oznaczył Twojego adresu e-mail jako zweryfikowanego.",
"errorIdentityConflict": "Ten adres e-mail należy do konta powiązanego już z inną tożsamością SSO.",
"errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.",
"errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie."
},
"mfa": {
"title": "Uwierzytelnianie dwuskładnikowe",
"totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.",
@@ -4238,7 +4247,16 @@
"other": "Inne",
"unknown": "Nieznane"
},
"subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę."
"subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę.",
"nutrients": {
"title": "Zsynchronizowane spożycie składników",
"description": "Dzienne sumy z Apple Health z ostatnich {days} dni. Tylko do odczytu — wartości należą do aplikacji źródłowej.",
"empty": "Nie zsynchronizowano jeszcze danych o składnikach odżywczych.",
"latest": "{amount} {unit} dnia {date}",
"daysWithData": "{count} z {days} dni",
"moduleHint": "Synchronizacją steruje moduł spożycia składników odżywczych.",
"moduleHintLink": "Zarządzaj modułami"
}
},
"ai": {
"title": "Dostawca AI",
@@ -7160,8 +7178,11 @@
"TOTAL_BODY_WATER": "Woda w organizmie",
"VISCERAL_FAT": "Tłuszcz trzewny",
"BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)",
"WAIST_CIRCUMFERENCE": "Obwód talii",
"PHQ9_SCORE": "PHQ-9 · nastrój",
"GAD7_SCORE": "GAD-7 · lęk"
"GAD7_SCORE": "GAD-7 · lęk",
"WHO5_SCORE": "WHO-5 · dobrostan",
"SCI_SCORE": "SCI · sen"
},
"typeGroups": {
"vitals": "Parametry życiowe",
@@ -7570,6 +7591,10 @@
"mentalHealth": {
"label": "Dobrostan psychiczny",
"description": "Dobrowolne testy PHQ-9 i GAD-7 obok dziennika nastroju. Domyślnie wyłączone; odpowiedzi są szyfrowane i pozostają poza Coachem AI."
},
"nutrients": {
"label": "Spożycie składników odżywczych",
"description": "Zapisuje dzienne sumy mikroskładników (witaminy, minerały, woda, kofeina) synchronizowane z Apple Health. Domyślnie wyłączone."
}
},
"illness": {
@@ -7726,12 +7751,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Saturacja tlenem",
"BODY_TEMPERATURE": "Temperatura ciała",
"SKIN_TEMPERATURE": "Temperatura skóry",
"RESPIRATORY_RATE": "Częstość oddechów",
"BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
"BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Wpływ na funkcjonowanie"
},
"listLoadError": "Nie udało się załadować epizodów."
},
@@ -8280,7 +8305,9 @@
"deleted": "Usunięto dokumenty: {count}",
"restored": "Przywrócono dokumenty: {count}",
"partialFailure": "Nie udało się zaktualizować {failed} z {total}",
"failed": "Nie udało się wykonać akcji"
"failed": "Nie udało się wykonać akcji",
"share": "Udostępnij",
"shareTooMany": "Link do udostępniania obejmuje maksymalnie {max} dokumentów. Odznacz kilka i spróbuj ponownie."
},
"episodeCard": {
"title": "Dokumenty",
@@ -8344,6 +8371,8 @@
},
"share": {
"title": "Udostępnij ten dokument",
"multiTitle": "Udostępnij te dokumenty",
"multiLabel": "{count} dokumentów",
"description": "Utwórz link tylko do odczytu dla lekarza z dołączonym tym dokumentem. Link i hasło pojawią się tylko raz."
},
"chat": {
@@ -8363,8 +8392,8 @@
"errorCredential": "Dostawcę AI trzeba ponownie połączyć w ustawieniach.",
"errorConsent": "Włącz zgodę na AI w ustawieniach, aby porozmawiać o tym dokumencie.",
"errorNetwork": "Wygląda na to, że jesteś offline. Sprawdź połączenie i spróbuj ponownie.",
"openAria": "Porozmawiaj o tym dokumencie",
"openButton": "Zapytaj o ten dokument",
"openAria": "Zapytaj Coacha o ten dokument",
"openButton": "Zapytaj Coacha",
"scopeBadge": "Rozmowa o: {title}"
}
},
@@ -8452,5 +8481,35 @@
"valuesDescription": "Wszystkie zapisane wartości tej metryki.",
"backToMetric": "Powrót do {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Witamina A",
"thiamin": "Tiamina (B1)",
"riboflavin": "Ryboflawina (B2)",
"niacin": "Niacyna (B3)",
"pantothenic_acid": "Kwas pantotenowy (B5)",
"vitamin_b6": "Witamina B6",
"biotin": "Biotyna (B7)",
"folate": "Foliany (B9)",
"vitamin_b12": "Witamina B12",
"vitamin_c": "Witamina C",
"vitamin_d": "Witamina D",
"vitamin_e": "Witamina E",
"vitamin_k": "Witamina K",
"calcium": "Wapń",
"iron": "Żelazo",
"magnesium": "Magnez",
"phosphorus": "Fosfor",
"zinc": "Cynk",
"copper": "Miedź",
"manganese": "Mangan",
"selenium": "Selen",
"chromium": "Chrom",
"molybdenum": "Molibden",
"iodine": "Jod",
"water": "Woda",
"caffeine": "Kofeina"
}
}
}
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.29",
"version": "1.28.41",
"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",
@@ -65,7 +65,7 @@
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.1",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-virtual": "^3.14.5",
"@types/sax": "^1.2.7",
@@ -74,6 +74,7 @@
"date-fns": "^4.4.0",
"dotenv": "^17.4.2",
"fflate": "^0.8.3",
"jose": "^6.2.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.18.0",
@@ -110,7 +111,7 @@
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.3",
"@types/node": "26.1.0",
"@types/node": "26.1.1",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
@@ -122,13 +123,13 @@
"eslint": "^9",
"eslint-config-next": "16.2.6",
"knip": "^6.16.1",
"lefthook": "^2.1.9",
"lefthook": "^2.1.10",
"pdf-parse": "^2.4.5",
"pdfjs-dist": "5.4.296",
"pg": "^8.22.0",
"prettier": "^3.9.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.9.0",
"shadcn": "^4.13.0",
"tailwindcss": "^4",
"testcontainers": "^12.0.3",
"tw-animate-css": "^1.4.0",
+6
View File
@@ -59,6 +59,12 @@ export default defineConfig({
// matches what real users render on first paint and is the only honest
// a11y baseline for this app.
colorScheme: "dark",
// Issue #490 — pin the browser timezone. Without this the context
// inherits the HOST zone (UTC on CI, Europe/Berlin on a local Mac),
// so any surface that renders a clock or a day boundary could pass
// locally and skew two hours / a calendar day on CI. Berlin matches
// the app's own display fallback, so CI and local render identically.
timezoneId: "Europe/Berlin",
},
projects: [
+558 -404
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,13 @@
-- v1.28 — OIDC SSO identity pinning.
--
-- The (oidc_issuer, oidc_sub) pair pins an account to exactly one IdP
-- identity: stamped on first OIDC login (auto-provision or one-time link by
-- verified email), matched on every later OIDC login. Email stays a display
-- field after the stamp — an IdP-side email change can never re-point the
-- login at a different account. Both columns are NULL on accounts that never
-- signed in via SSO; Postgres unique indexes ignore NULL pairs, so the
-- constraint only bites on actual duplicate identities.
ALTER TABLE "users" ADD COLUMN "oidc_issuer" TEXT;
ALTER TABLE "users" ADD COLUMN "oidc_sub" TEXT;
CREATE UNIQUE INDEX "users_oidc_issuer_oidc_sub_key" ON "users"("oidc_issuer", "oidc_sub");
@@ -0,0 +1,37 @@
-- v1.28 — micronutrient intake daily totals (opt-in `nutrients` module).
--
-- One row per (user, local day, nutrient): vitamins, minerals, water and
-- caffeine as supplement-style day totals synced from Apple Health. The
-- composite PK is the idempotency key — an iOS re-post of a grown day
-- total replaces the row in place (last-writer-wins day-total contract),
-- so there is no externalId column and no soft delete. `day` is a
-- YYYY-MM-DD TEXT key anchored to the user's IANA timezone (the
-- EnvironmentContext / MedicationComplianceRollup day-key precedent);
-- `nutrient` is a bare TEXT code enforced in code against the closed
-- catalog (`src/lib/nutrients/catalog.ts`) so adding a code never needs
-- a migration. `unit` is denormalised from the catalog on purpose: rows
-- stay self-describing in exports even if the catalog ever drifts.
CREATE TABLE "nutrient_intake_days" (
"user_id" TEXT NOT NULL,
"day" TEXT NOT NULL,
"nutrient" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"unit" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'APPLE_HEALTH',
"external_source_version" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "nutrient_intake_days_pkey" PRIMARY KEY ("user_id", "day", "nutrient")
);
-- Read path: the settings-card / read-endpoint window scan per user, and
-- the later per-nutrient series fetch (day DESC for "latest first").
CREATE INDEX "nutrient_intake_days_user_id_nutrient_day_idx"
ON "nutrient_intake_days" ("user_id", "nutrient", "day" DESC);
-- Account deletion cascades; rows are worthless without their owner.
ALTER TABLE "nutrient_intake_days"
ADD CONSTRAINT "nutrient_intake_days_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- Import-job liveness heartbeat (issue #486 follow-up).
--
-- `reconcileOrphanImportJobs` used to flip EVERY non-terminal ImportJob
-- row to `failed` on any worker boot — killing an import that was still
-- actively parsing in another worker (multi-replica / rolling deploy).
-- The reconcile now gates on this heartbeat: a non-terminal row is only
-- treated as orphaned when its pg-boss job is gone/terminal OR its
-- heartbeat has gone stale. `@updatedAt` bumps this column on every
-- write, including each mid-run progress tick, so a live import keeps it
-- fresh. Existing rows backfill to the ALTER time via the DB default.
ALTER TABLE "import_jobs"
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
+61
View File
@@ -42,6 +42,13 @@ model User {
email String? @unique
passwordHash String? @map("password_hash") // argon2id, optional fallback
role String @default("USER") // "ADMIN" | "USER"
// OIDC SSO — the (issuer, sub) pair pins the account to exactly one IdP
// identity. Stamped on first OIDC login (auto-provision or one-time link
// by verified email); every later OIDC login matches on this pair, never
// on the email, so an IdP-side email change can update the display email
// but can never re-point the login at a different account.
oidcIssuer String? @map("oidc_issuer")
oidcSub String? @map("oidc_sub")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -805,6 +812,10 @@ model User {
// that win over the home location for the days they cover.
environmentContexts EnvironmentContext[]
environmentTravelLocations EnvironmentTravelLocation[]
// v1.28 — micronutrient intake daily totals synced from Apple Health
// (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off
// by default; the ingest route refuses while the module is off.
nutrientIntakeDays NutrientIntakeDay[]
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
// discharge letters) the user uploads. The raw document is encrypted at
// rest; extracted facts land in a review-then-confirm staging area
@@ -867,6 +878,10 @@ model User {
// v1.16.0 — per-signup invite redemption ledger (multi-use history).
inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
// One account per IdP identity — the (issuer, sub) pair is the durable
// OIDC key (both NULL on accounts that never signed in via SSO; Postgres
// unique indexes ignore NULL pairs).
@@unique([oidcIssuer, oidcSub])
@@map("users")
}
@@ -4074,6 +4089,12 @@ model ImportJob {
exportedAt DateTime? @map("exported_at")
startedAt DateTime @default(now()) @map("started_at")
completedAt DateTime? @map("completed_at")
/// Bumped on every write, including each mid-run progress tick. Acts
/// as a liveness heartbeat: `reconcileOrphanImportJobs` treats a
/// non-terminal row whose heartbeat has gone stale as orphaned, so a
/// job still actively parsing in another worker (fresh heartbeat) is
/// never flipped to `failed` (issue #486 follow-up).
updatedAt DateTime @updatedAt @map("updated_at")
/// JSON envelope mirroring `ImportJobProgress` (`src/lib/measurements/
/// import-apple-health-export.ts`). Updated mid-run.
progress Json @default("{}")
@@ -6201,3 +6222,43 @@ model DocumentThumbnail {
@@index([userId])
@@map("document_thumbnails")
}
// ─── Nutrient intake (v1.28) ───────────────────────────────────────
//
// Micronutrient daily totals synced from Apple Health — vitamins,
// minerals, water, caffeine as supplement-style "substances taken",
// never a food diary (energy / macros are out of scope by directive).
// One row per (user, local day, nutrient); the composite PK IS the
// idempotency key, so an iOS re-post of a grown day total replaces the
// row in place (last-writer-wins day-total contract — the `stats:`
// overwrite semantics without externalId string parsing). `nutrient`
// is a bare string enforced in code against the closed catalog in
// `src/lib/nutrients/catalog.ts` (the IntegrationStatus precedent: no
// migration to add a code). No soft delete: rows are source-owned day
// totals; replace-on-repost is the correction mechanism and account
// deletion cascades. Behind the opt-in `nutrients` module
// (refuse-ingest-when-off).
model NutrientIntakeDay {
userId String @map("user_id")
/// YYYY-MM-DD anchored to the user's IANA timezone (iOS-computed —
/// the same day-key contract as the `stats:` externalIds).
day String
/// Closed catalog code ("vitamin_d", "magnesium", "water", ...).
nutrient String
/// Daily total in the catalog's canonical unit.
amount Float
/// Canonical unit echoed from the catalog ("mg" | "ug" | "ml") —
/// denormalised on purpose: rows stay self-describing in exports
/// even if the catalog ever drifts.
unit String
source String @default("APPLE_HEALTH")
externalSourceVersion String? @map("external_source_version")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, day, nutrient])
@@index([userId, nutrient, day(sort: Desc)])
@@map("nutrient_intake_days")
}
+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.28.29";
/* @sw-version-fallback */ "v1.28.41";
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/*`
+52 -12
View File
@@ -37,17 +37,9 @@
},
{
"name": "Withings OAuth",
"description": "Required to enable the Withings integration. App boots without these but Withings sync stays disabled.",
"description": "Withings API credentials are entered per user in Settings → Integrations (no env-var path). Only the webhook shared secret is operator-level.",
"required": false,
"variables": [
{
"name": "WITHINGS_CLIENT_ID",
"purpose": "Withings OAuth client_id from https://developer.withings.com."
},
{
"name": "WITHINGS_CLIENT_SECRET",
"purpose": "Withings OAuth client_secret."
},
{
"name": "WITHINGS_WEBHOOK_SECRET",
"purpose": "Random secret carried as a path segment in the Withings webhook callback URL. Generate with `openssl rand -hex 32`."
@@ -86,6 +78,45 @@
}
]
},
{
"name": "OIDC SSO login",
"description": "Required to enable SSO login against a self-hosted identity provider (Authentik, Keycloak, Authelia, Google Workspace, etc.). App boots without these but the login page's SSO button stays hidden. Register a confidential client with redirect URI ${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback.",
"required": false,
"allOrNone": true,
"variables": [
{
"name": "OIDC_ISSUER_URL",
"purpose": "OIDC provider issuer URL (discovery doc served at <issuer>/.well-known/openid-configuration)."
},
{
"name": "OIDC_CLIENT_ID",
"purpose": "OIDC confidential client id."
},
{
"name": "OIDC_CLIENT_SECRET",
"purpose": "OIDC confidential client secret."
}
]
},
{
"name": "OIDC SSO options",
"description": "Optional tuning for an enabled OIDC provider. Each stands alone (deliberately NOT allOrNone — any subset is valid) and each has a safe default when unset.",
"required": false,
"variables": [
{
"name": "OIDC_SCOPES",
"purpose": "Space-separated scopes requested at authorization. Default: \"openid email profile\"."
},
{
"name": "OIDC_BUTTON_LABEL",
"purpose": "Label on the login page's SSO button. Default: \"Single Sign-On\"."
},
{
"name": "OIDC_ONLY",
"purpose": "\"true\" disables password/passkey login server-side (only effective when the OIDC provider group is fully configured). Default: additive login. Locks the native iOS app out of sign-in."
}
]
},
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
@@ -167,9 +198,18 @@
"name": "BACKUP_S3_ENDPOINT",
"purpose": "S3-compatible endpoint URL."
},
{ "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." },
{ "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." },
{ "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." },
{
"name": "BACKUP_S3_BUCKET",
"purpose": "Bucket name."
},
{
"name": "BACKUP_S3_ACCESS_KEY",
"purpose": "S3 access key."
},
{
"name": "BACKUP_S3_SECRET_KEY",
"purpose": "S3 secret key."
},
{
"name": "BACKUP_ENCRYPTION_KEY",
"purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { resolveKey } from "@/lib/i18n/resolve-key";
import {
ILLNESS_SCAN_TYPES,
FUNCTIONAL_IMPACT_RETURN_KEY,
} from "@/lib/illness/correlation";
import {
illnessTypeEnum,
illnessLifecycleEnum,
} from "@/lib/validations/illness";
import {
allergyCategoryEnum,
allergyTypeEnum,
allergySeverityEnum,
allergyStatusEnum,
} from "@/lib/validations/allergy";
import { familyRelationshipEnum } from "@/lib/validations/family-history";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
/**
* Enum-driven exhaustiveness guard for DYNAMICALLY-built i18n keys.
*
* The three existing i18n guards each see one face of the problem and miss the
* fourth:
* - `i18n-call-site-coverage.test.ts` sees only literal `t("ns.key")` calls,
* so a runtime-assembled `t(\`ns.${x}\`)` key is invisible to it.
* - `i18n-reverse-coverage.test.ts` sees bundle orphans, but treats any
* `ns.${x}` template as covering its whole subtree — so a MEMBER missing
* from the subtree is invisible.
* - `i18n-locale-integrity.test.ts` sees cross-locale parity, so it only
* fires when a key is present in SOME locale but not all.
*
* None of them assert the class that actually ships broken: "for enum E, every
* member's dynamically-built key resolves, non-empty, in every locale." Because
* parity is perfect, a key missing from ONE locale is missing from ALL SIX at
* once — exactly the seam that let `measurementReminders.types.{WHO5_SCORE,
* SCI_SCORE,WAIST_CIRCUMFERENCE}` (v1.28.38) and `illness.vital.FUNCTIONAL_IMPACT`
* (v1.28.41) ship absent from every bundle.
*
* This test derives the EXPECTED key set from the SOURCE enum/const and asserts
* every `prefix + member` resolves. Adding an enum member without a bundle key
* fails CI by construction — no hand-maintained floor to drift.
*
* ── Covered key spaces (source is a clean, finite, import-safe enum/const) ──
* - illness.vital.* ILLNESS_SCAN_TYPES ∪ FUNCTIONAL_IMPACT_RETURN_KEY
* - illness.type.* illnessTypeEnum
* - illness.lifecycle.* illnessLifecycleEnum
* - records.allergies.category.* allergyCategoryEnum
* - records.allergies.type.* allergyTypeEnum
* - records.allergies.severity.* allergySeverityEnum ∪ "NONE" sentinel
* - records.allergies.status.* allergyStatusEnum
* - records.family.relationship.* familyRelationshipEnum
* - mentalHealth.instrument.* } INSTRUMENTS i18nKey slugs
* - mentalHealth.instrumentSub.* } (PHQ-9 / GAD-7 / WHO-5 / SCI —
* - mentalHealth.instrumentDescription.* } the WHO-5/SCI class that bit before)
*
* ── Deliberately EXCLUDED (would produce false positives) ──
* - `insights.workouts.sport.*` — the Strava render path stores a raw
* `sport_type` and the render site legitimately falls back to that raw
* string, so a "missing" key is a graceful degradation, not a broken token
* (audit LOW #2). Asserting it would fail on a path that is by-design lax.
* - `settings.sections.*.description`, `settings.ai.providerChain.types.admin-codex`,
* `insights.<metric>.emptyState.cta` — structurally unreachable behind a
* tsc-exhaustive map / a `!= null` guard / a server-only filter (audit LOW).
* Registering them would demand bundle keys for code paths users never hit.
* - `measurementReminders.types.*` — already exhaustively covered by
* `measurement-reminders/__tests__/type-labels-i18n.test.ts`; its source
* const lives in a client component, not a lib-level export.
* - Prisma cycle enums, medication/document/lab/nutrient catalogs — clean
* enums the audit flags as candidates; left for a follow-up extension. Add
* them here once their exact prefix→member mapping is confirmed 1:1 (the
* cycle cervix* prefixes and catalog slug maps need per-space verification
* to stay false-positive-free).
*/
const MESSAGES_DIR = join(__dirname, "../../messages");
const LOCALES = readdirSync(MESSAGES_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => ({
locale: f.replace(/\.json$/, ""),
messages: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as Record<
string,
unknown
>,
}));
/** PHQ-9 / GAD-7 / WHO-5 / SCI lowercase i18n slugs, straight from the source. */
const INSTRUMENT_SLUGS = Object.values(INSTRUMENTS).map((i) => i.i18nKey);
interface KeySpace {
/** The dotted prefix the key is built from (`t(\`prefix.${member}\`)`). */
prefix: string;
/** The finite member set, derived from the SOURCE enum/const. */
members: readonly string[];
}
const REGISTRY: readonly KeySpace[] = [
// illness.* — the currently-broken seed (FUNCTIONAL_IMPACT wins recovery-gap ties).
{
prefix: "illness.vital",
members: [...ILLNESS_SCAN_TYPES.map(String), FUNCTIONAL_IMPACT_RETURN_KEY],
},
{ prefix: "illness.type", members: illnessTypeEnum.options },
{ prefix: "illness.lifecycle", members: illnessLifecycleEnum.options },
// records.allergies.* — Zod enums; severity carries a "NONE" (not-assessed) sentinel.
{
prefix: "records.allergies.category",
members: allergyCategoryEnum.options,
},
{ prefix: "records.allergies.type", members: allergyTypeEnum.options },
{
prefix: "records.allergies.severity",
members: [...allergySeverityEnum.options, "NONE"],
},
{ prefix: "records.allergies.status", members: allergyStatusEnum.options },
// records.family.relationship.*
{
prefix: "records.family.relationship",
members: familyRelationshipEnum.options,
},
// mentalHealth.* — the WHO-5/SCI class whose absence originally motivated this guard.
{ prefix: "mentalHealth.instrument", members: INSTRUMENT_SLUGS },
{ prefix: "mentalHealth.instrumentSub", members: INSTRUMENT_SLUGS },
{ prefix: "mentalHealth.instrumentDescription", members: INSTRUMENT_SLUGS },
];
describe("dynamic-key exhaustiveness (enum-derived)", () => {
it("discovers all six shipped locales", () => {
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
"de",
"en",
"es",
"fr",
"it",
"pl",
]);
});
it("every registered key space has a non-empty member set", () => {
for (const space of REGISTRY) {
expect(
space.members.length,
`${space.prefix} resolved to an empty member set — source import broke`,
).toBeGreaterThan(0);
}
});
for (const { locale, messages } of LOCALES) {
for (const space of REGISTRY) {
for (const member of space.members) {
const key = `${space.prefix}.${member}`;
it(`resolves ${key} in ${locale}`, () => {
const value = resolveKey(messages, key);
expect(value, `${key} missing in ${locale}.json`).toBeTypeOf(
"string",
);
expect((value ?? "").trim().length).toBeGreaterThan(0);
});
}
}
}
});
@@ -111,6 +111,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
// ungated insights AI route fails this test BY NAME rather than leaking the
// surface over a Bearer token when the account turned insights off.
"src/app/api/insights",
// v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the
// data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the
// mental-health posture): both the batch ingest and the window-summary
// read call `requireModuleEnabled(user.id, "nutrients")` directly, so a
// phone whose user never opted in cannot land rows server-side. Walking
// the tree means a NEW ungated nutrients route fails BY NAME.
"src/app/api/nutrients",
];
/**
@@ -32,6 +32,8 @@ const mocks = vi.hoisted(() => ({
importJobFindFirst: vi.fn().mockResolvedValue(null),
importJobUpdate: vi.fn(),
userFindUnique: vi.fn(),
streamToDisk: vi.fn(),
unlink: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -42,6 +44,15 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: mocks.getGlobalBoss,
}));
vi.mock("@/lib/multipart/stream-to-disk", () => ({
streamMultipartToDisk: mocks.streamToDisk,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, unlink: mocks.unlink };
});
vi.mock("@/lib/db", () => ({
prisma: {
importJob: {
@@ -67,6 +78,8 @@ const USER_SESSION = {
user: { id: "user-2", username: "alice", role: "USER" as const },
};
const STAGED_PATH = "/tmp/healthlog-admin-apple-health-import-abc.bin";
beforeEach(() => {
vi.resetAllMocks();
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
@@ -76,8 +89,27 @@ beforeEach(() => {
mocks.importJobFindFirst.mockResolvedValue(null);
mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" });
mocks.userFindUnique.mockResolvedValue({ id: "user-2", username: "alice" });
mocks.streamToDisk.mockResolvedValue({
filePath: STAGED_PATH,
bytes: 100,
sha256: "deadbeef",
originalFilename: "export.zip",
textFields: { userId: "user-2" },
});
mocks.unlink.mockResolvedValue(undefined);
});
function multipartReq(): NextRequest {
return new NextRequest(
"http://localhost/api/admin/import-apple-health-export",
{
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
},
);
}
describe("POST /api/admin/import-apple-health-export", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -139,4 +171,45 @@ describe("POST /api/admin/import-apple-health-export", () => {
const res = await POST(req);
expect(res.status).toBe(429);
});
it("re-stages and enqueues when the only prior job for the same bytes is failed (issue #486)", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
mocks.importJobFindFirst.mockImplementation(async (args: unknown) => {
const where = (args as { where?: { status?: { not?: string } } }).where;
if (where?.status?.not === "failed") return null;
return { id: "old-failed-job", status: "failed" };
});
const res = await POST(multipartReq());
expect(res.status).toBe(202);
const body = await res.json();
expect(body.data.status).toBe("queued");
expect(body.data.idempotent).toBeUndefined();
expect(body.data.jobId).not.toBe("old-failed-job");
expect(mocks.bossSend).toHaveBeenCalledTimes(1);
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: { not: "failed" } }),
}),
);
expect(mocks.unlink).not.toHaveBeenCalled();
});
it("short-circuits to a viable prior job and unlinks the redundant upload", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
mocks.importJobFindFirst.mockResolvedValue({
id: "live-job",
status: "parsing",
});
const res = await POST(multipartReq());
expect(res.status).toBe(202);
const body = await res.json();
expect(body.data.idempotent).toBe(true);
expect(body.data.jobId).toBe("live-job");
expect(mocks.bossSend).not.toHaveBeenCalled();
expect(mocks.importJobCreate).not.toHaveBeenCalled();
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
});
});
@@ -22,9 +22,11 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_QUEUE,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
import { unlink } from "node:fs/promises";
export const dynamic = "force-dynamic";
@@ -111,15 +113,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Same content-hash idempotency as the user-facing route, but scoped
// by the target user — an admin re-uploading the same file for the
// same user resolves to the previous job.
// same user resolves to the previous job. As on the user route, a
// prior `failed` job is excluded (issue #486) so the same export can
// be retried; only still-viable jobs (queued / in-flight / done)
// short-circuit.
const existing = await prisma.importJob.findFirst({
where: {
userId: targetUser.id,
uploadSha256: uploaded.sha256,
status: { not: "failed" },
},
orderBy: { startedAt: "desc" },
});
if (existing) {
// Redundant staging file — the existing viable job owns the bytes.
// Unlink so a deduped admin re-upload does not leak `/tmp`.
await unlink(uploaded.filePath).catch(() => {});
annotate({ meta: { idempotent_hit: true, job_id: existing.id } });
return apiSuccess(
{
@@ -158,7 +167,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
uploadBytes: uploaded.bytes,
enqueuedAt: new Date().toISOString(),
};
const bossJobId = await boss.send(APPLE_HEALTH_IMPORT_QUEUE, payload);
// No retries + wide expiration: the staged upload is consumed by the
// first run, so a redelivery could only fail on the deleted `/tmp`
// file and mask the real outcome (see the worker's send-options doc).
const bossJobId = await boss.send(
APPLE_HEALTH_IMPORT_QUEUE,
payload,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
);
await prisma.importJob.update({
where: { id: importJob.id },
+46 -1
View File
@@ -5,7 +5,7 @@
* only signal operators need; the identifier itself is PII and stays
* out of the row.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -135,3 +135,48 @@ describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
expect(JSON.stringify(payload)).not.toContain(TYPED_EMAIL);
});
});
describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
it("rejects password login before touching the DB when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(prisma.user.findFirst).not.toHaveBeenCalled();
});
it("still allows password login when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
// Falls through to normal password verification (which fails here
// since verifyPassword is mocked to return false) rather than 403 —
// a half-set OIDC group must never lock everyone out.
expect(res.status).toBe(401);
});
});
+10
View File
@@ -13,8 +13,18 @@ import { finishLogin } from "@/lib/auth/login-response";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
// button — otherwise a pre-existing password (or a leaked/reused one)
// stays a live bypass of the operator's SSO-only policy.
if (isOidcOnly()) {
return apiError("Password login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
// global bucket when the trust chain is misconfigured; otherwise it
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
@@ -0,0 +1,483 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ setError: vi.fn() }),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((s: string) => s.replace(/^enc\(/, "").replace(/\)$/, "")),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findFirst: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
appSettings: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
},
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn() }));
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
vi.mock("@/lib/auth/mfa/challenge", () => ({
createMfaChallenge: vi.fn(async () => ({
ticket: "mfa-ticket-1",
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
})),
}));
vi.mock("@/lib/auth/mfa-enrollment", () => ({
syncMfaEnrollCookie: vi.fn(),
}));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => false,
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveServerDefaultTimezone: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/auth/oidc", () => ({
getOidcConfig: vi.fn(),
discoverOidcMetadata: vi.fn(),
exchangeCodeForTokens: vi.fn(),
verifyIdToken: vi.fn(),
fetchUserinfoEmail: vi.fn(),
getOidcRedirectUri: () =>
"https://healthlog.example.com/api/auth/oidc/callback",
deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]),
}));
import { GET } from "../route";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import {
getOidcConfig,
discoverOidcMetadata,
exchangeCodeForTokens,
verifyIdToken,
} from "@/lib/auth/oidc";
const CONFIG = {
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
};
const METADATA = {
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
};
/** A minimal user row shaped like what the callback consumes. */
function userRow(overrides: Record<string, unknown> = {}) {
return {
id: "user-1",
email: "existing@example.com",
onboardingCompletedAt: new Date(),
oidcIssuer: null,
oidcSub: null,
totpConfirmedAt: null,
mfaEnforced: false,
...overrides,
};
}
/**
* The callback's `prisma.user.findFirst` calls are distinguishable by
* their `where` shape: the (issuer, sub) identity lookup, the display-
* email collision probe (`NOT`), and the link/provision email match.
*/
function mockUserLookups(opts: {
byIdentity?: ReturnType<typeof userRow> | null;
byEmail?: ReturnType<typeof userRow> | null;
emailTaken?: boolean;
}) {
vi.mocked(prisma.user.findFirst).mockImplementation(((args: {
where: Record<string, unknown>;
}) => {
if (args.where.oidcIssuer !== undefined) {
return Promise.resolve(opts.byIdentity ?? null);
}
if (args.where.NOT !== undefined) {
return Promise.resolve(opts.emailTaken ? { id: "user-other" } : null);
}
return Promise.resolve(opts.byEmail ?? null);
}) as never);
}
function makeRequest(opts: { query?: string; cookie?: string }): NextRequest {
const req = new NextRequest(
`http://localhost/api/auth/oidc/callback${opts.query ?? ""}`,
);
if (opts.cookie) {
req.cookies.set("oidc_auth_state", opts.cookie);
}
return req;
}
function stateCookie(payload: Record<string, unknown>): string {
return `enc(${JSON.stringify(payload)})`;
}
/** A request whose query/state pass the CSRF gate — the default fixture. */
function validRequest(next = "/"): NextRequest {
return makeRequest({
query: "?code=abc&state=STATE",
cookie: stateCookie({
state: "STATE",
nonce: "nonce-1",
codeVerifier: "verifier-1",
next,
}),
});
}
function mockIdentity(overrides: Record<string, unknown> = {}) {
vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" });
vi.mocked(verifyIdToken).mockResolvedValue({
sub: "sub-1",
email: "existing@example.com",
emailVerified: true,
name: null,
...overrides,
} as never);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 20,
reset: 0,
ip: "1.2.3.4",
} as never);
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0);
vi.mocked(createMfaChallenge).mockResolvedValue({
ticket: "mfa-ticket-1",
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
});
describe("GET /api/auth/oidc/callback", () => {
it("redirects with oidc_denied when the IdP reports an error", async () => {
const res = await GET(makeRequest({ query: "?error=access_denied" }));
expect(res.headers.get("location")).toContain("error=oidc_denied");
});
it("redirects with oidc_state when code/state are missing", async () => {
const res = await GET(makeRequest({}));
expect(res.headers.get("location")).toContain("error=oidc_state");
});
it("redirects with oidc_state when the state cookie is missing", async () => {
const res = await GET(makeRequest({ query: "?code=abc&state=xyz" }));
expect(res.headers.get("location")).toContain("error=oidc_state");
});
it("redirects with oidc_state on a state/cookie mismatch (CSRF)", async () => {
const res = await GET(
makeRequest({
query: "?code=abc&state=WRONG",
cookie: stateCookie({
state: "RIGHT",
nonce: "nonce-1",
codeVerifier: "verifier-1",
next: "/",
}),
}),
);
expect(res.headers.get("location")).toContain("error=oidc_state");
});
it("redirects with oidc_no_email when the identity has no email at all", async () => {
mockIdentity({ email: null, emailVerified: undefined });
mockUserLookups({ byIdentity: null });
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain("error=oidc_no_email");
});
it("redirects with oidc_email_unverified when email_verified is absent", async () => {
// An IdP that never asserts verification cannot anchor a link or a
// provision — absent is a reject, not a benefit of the doubt.
mockIdentity({ emailVerified: undefined });
mockUserLookups({ byIdentity: null, byEmail: null });
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain(
"error=oidc_email_unverified",
);
expect(prisma.user.create).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
});
it("redirects with oidc_email_unverified when email_verified is false", async () => {
mockIdentity({ emailVerified: false });
mockUserLookups({ byIdentity: null, byEmail: null });
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain(
"error=oidc_email_unverified",
);
});
it("logs in by (issuer, sub) without consulting the email", async () => {
// Even with a brand-new email at the IdP, the stamped identity wins —
// no second account is provisioned and no re-link happens.
mockIdentity({ email: "renamed@example.com", emailVerified: true });
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
}),
emailTaken: false,
});
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({
email: "renamed@example.com",
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
}) as never,
);
const res = await GET(validRequest("/dashboard"));
expect(prisma.user.create).not.toHaveBeenCalled();
// Display email refresh — the only field the update touches.
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { email: "renamed@example.com" },
});
expect(createSession).toHaveBeenCalled();
expect(res.headers.get("location")).toContain("/dashboard");
});
it("skips the display-email refresh when another account holds the address", async () => {
mockIdentity({ email: "collide@example.com", emailVerified: true });
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
}),
emailTaken: true,
});
const res = await GET(validRequest());
expect(prisma.user.update).not.toHaveBeenCalled();
expect(createSession).toHaveBeenCalled();
expect(res.status).toBe(307);
});
it("links an existing email-matched account ONCE, stamping (issuer, sub)", async () => {
mockIdentity();
mockUserLookups({ byIdentity: null, byEmail: userRow() });
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
);
const res = await GET(validRequest("/dashboard"));
expect(prisma.user.create).not.toHaveBeenCalled();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { oidcIssuer: METADATA.issuer, oidcSub: "sub-1" },
});
expect(auditLog).toHaveBeenCalledWith(
"auth.oidc.linked",
expect.objectContaining({ userId: "user-1" }),
);
expect(createSession).toHaveBeenCalled();
expect(res.headers.get("location")).toContain("/dashboard");
});
it("rejects a login whose email matches an account bound to a DIFFERENT identity", async () => {
mockIdentity({ sub: "sub-intruder" });
mockUserLookups({
byIdentity: null,
byEmail: userRow({
oidcIssuer: "https://other-idp.example.com",
oidcSub: "sub-original",
}),
});
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain(
"error=oidc_identity_conflict",
);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
expect(auditLog).toHaveBeenCalledWith(
"auth.oidc.link_conflict",
expect.objectContaining({ userId: "user-1" }),
);
});
it("auto-provisions a new user when registration is open", async () => {
mockIdentity({
sub: "sub-2",
email: "New@Example.com",
emailVerified: true,
});
mockUserLookups({ byIdentity: null, byEmail: null });
vi.mocked(prisma.user.count).mockResolvedValue(3);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
registrationEnabled: true,
} as never);
vi.mocked(prisma.user.create).mockResolvedValue(
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
);
const res = await GET(validRequest());
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
// Lowercased at provision time; stamped with the durable identity
// and the same server-default timezone the register route resolves.
email: "new@example.com",
passwordHash: null,
role: "USER",
timezone: "Europe/Berlin",
oidcIssuer: METADATA.issuer,
oidcSub: "sub-2",
}),
}),
);
expect(createSession).toHaveBeenCalled();
expect(res.status).toBe(307);
});
it("refuses to auto-provision when registration is closed", async () => {
mockIdentity({ sub: "sub-3", email: "blocked@example.com" });
mockUserLookups({ byIdentity: null, byEmail: null });
vi.mocked(prisma.user.count).mockResolvedValue(5);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
registrationEnabled: false,
} as never);
const res = await GET(validRequest());
expect(prisma.user.create).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
expect(res.headers.get("location")).toContain(
"error=oidc_registration_disabled",
);
});
it("never stamps the session step-up fresh — the 5th createSession arg is null", async () => {
mockIdentity();
mockUserLookups({ byIdentity: null, byEmail: userRow() });
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
);
await GET(validRequest());
expect(createSession).toHaveBeenCalledWith(
"user-1",
false,
"1.2.3.4",
null,
null,
);
});
it("routes a TOTP-enrolled account through the MFA challenge instead of logging in", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
totpConfirmedAt: new Date(),
}),
});
const res = await GET(validRequest("/dashboard"));
// No session — the partial state lives in the single-use ticket.
expect(createSession).not.toHaveBeenCalled();
expect(createMfaChallenge).toHaveBeenCalledWith("user-1", "login");
const location = res.headers.get("location")!;
expect(location).toContain("/auth/login");
expect(location).not.toContain("error=");
// `next` continuation rides the query string into the login page.
expect(location).toContain(`next=${encodeURIComponent("/dashboard")}`);
// The ticket + methods ride the login-page-scoped handoff cookie.
const setCookie = res.headers.getSetCookie().join("\n");
expect(setCookie).toContain("oidc_mfa_handoff=");
expect(setCookie).toContain(encodeURIComponent("mfa-ticket-1"));
expect(setCookie.toLowerCase()).toContain("path=/auth/login");
expect(decodeURIComponent(setCookie)).toContain('"totp"');
});
it("offers only the security-key method for a WebAuthn-only account", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
totpConfirmedAt: null,
}),
});
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1);
const res = await GET(validRequest());
expect(createSession).not.toHaveBeenCalled();
const setCookie = decodeURIComponent(res.headers.getSetCookie().join("\n"));
expect(setCookie).toContain('"webauthn"');
expect(setCookie).not.toContain('"totp"');
});
it("redirects with oidc_failed when the token exchange throws", async () => {
vi.mocked(exchangeCodeForTokens).mockRejectedValue(new Error("network"));
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain("error=oidc_failed");
});
it("deletes the state cookie with the exact path it was set under", async () => {
// RFC 6265 keys cookies by (name, domain, path) — a deletion without
// `Path=/api/auth/oidc` would target a `/`-scoped cookie that does not
// exist and leave the real single-use blob alive.
const errorRes = await GET(makeRequest({ query: "?error=access_denied" }));
const errorCookie = errorRes.headers
.getSetCookie()
.find((c) => c.startsWith("oidc_auth_state="))!;
expect(errorCookie.toLowerCase()).toContain("path=/api/auth/oidc");
mockIdentity();
mockUserLookups({
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
});
const successRes = await GET(validRequest());
const successCookie = successRes.headers
.getSetCookie()
.find((c) => c.startsWith("oidc_auth_state="))!;
expect(successCookie.toLowerCase()).toContain("path=/api/auth/oidc");
});
});
+394
View File
@@ -0,0 +1,394 @@
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import {
deriveUniqueUsername,
discoverOidcMetadata,
exchangeCodeForTokens,
fetchUserinfoEmail,
getOidcConfig,
getOidcRedirectUri,
verifyIdToken,
} from "@/lib/auth/oidc";
import {
OIDC_MFA_COOKIE,
OIDC_MFA_COOKIE_PATH,
OIDC_MFA_TTL_MS,
OIDC_STATE_COOKIE,
OIDC_STATE_COOKIE_PATH,
} from "@/lib/auth/oidc-cookie";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { resolveServerDefaultTimezone } from "@/lib/tz/resolver";
const LOGIN_ERROR_URL = "/auth/login";
function errorRedirect(req: NextRequest, reason: string): NextResponse {
const response = NextResponse.redirect(
new URL(`${LOGIN_ERROR_URL}?error=${reason}`, req.url),
);
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
* delete would target `/` and never match, leaving the single-use blob
* alive for the rest of its TTL).
*/
function deleteStateCookie(response: NextResponse): void {
response.cookies.delete({
name: OIDC_STATE_COOKIE,
path: OIDC_STATE_COOKIE_PATH,
});
}
/**
* Closed set of RFC 6749 §4.1.2.1 / OIDC Core §3.1.2.6 authorization error
* codes. The wide-event meta only ever carries a member of this set (or
* "other") — never the raw, attacker-influenceable query value.
*/
const KNOWN_IDP_ERROR_CODES = new Set([
"access_denied",
"invalid_request",
"unauthorized_client",
"unsupported_response_type",
"invalid_scope",
"server_error",
"temporarily_unavailable",
"interaction_required",
"login_required",
"account_selection_required",
"consent_required",
"invalid_request_uri",
"invalid_request_object",
"request_not_supported",
"request_uri_not_supported",
"registration_not_supported",
]);
interface StoredOidcState {
state: string;
nonce: string;
codeVerifier: string;
next: string;
}
function isStoredOidcState(value: unknown): value is StoredOidcState {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Record<string, unknown>).state === "string" &&
typeof (value as Record<string, unknown>).nonce === "string" &&
typeof (value as Record<string, unknown>).codeVerifier === "string" &&
typeof (value as Record<string, unknown>).next === "string"
);
}
/**
* OIDC callback. Must work with NO existing session — this route
* authenticates the user in the first place, unlike the per-account
* integration callbacks (Withings/Polar/etc.) which all require
* `requireAuth()`. Every failure branch redirects to `/auth/login?error=...`
* (never JSON — this is a top-level browser navigation) and deletes the
* single-use state cookie, mirroring `src/app/api/withings/callback/route.ts`.
*/
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.callback" } });
const config = getOidcConfig();
if (!config) return errorRedirect(req, "oidc_disabled");
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:oidc:callback",
20,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) return errorRedirect(req, "oidc_rate_limited");
const { searchParams } = req.nextUrl;
const idpError = searchParams.get("error");
if (idpError) {
annotate({
meta: {
reason: "idp_denied",
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
},
});
return errorRedirect(req, "oidc_denied");
}
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code || !state) return errorRedirect(req, "oidc_state");
const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
let stored: StoredOidcState | null = null;
if (rawCookie) {
try {
const parsed: unknown = JSON.parse(decrypt(rawCookie));
if (isStoredOidcState(parsed)) stored = parsed;
} catch {
stored = null;
}
}
if (!stored) return errorRedirect(req, "oidc_state");
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
if (
state.length !== stored.state.length ||
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
) {
annotate({ meta: { reason: "csrf" } });
return errorRedirect(req, "oidc_state");
}
try {
const metadata = await discoverOidcMetadata(config);
const redirectUri = getOidcRedirectUri();
const tokens = await exchangeCodeForTokens({
metadata,
config,
code,
codeVerifier: stored.codeVerifier,
redirectUri,
});
if (!tokens.id_token) return errorRedirect(req, "oidc_failed");
const identity = await verifyIdToken({
metadata,
config,
idToken: tokens.id_token,
nonce: stored.nonce,
});
let email = identity.email;
let emailVerified = identity.emailVerified;
if (!email && tokens.access_token) {
const userinfo = await fetchUserinfoEmail({
metadata,
accessToken: tokens.access_token,
});
email = userinfo.email;
emailVerified = userinfo.emailVerified;
}
// (1) Durable identity match — the (issuer, sub) pair stamped on a
// previous login, link, or provision. Email is deliberately NOT
// consulted here: after the stamp it is a display field, so an
// IdP-side email change can refresh it below but can never re-point
// the login at a different account.
let user = await prisma.user.findFirst({
where: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
});
if (user) {
// Refresh the display email only — and only from a claim the IdP
// explicitly marks verified. Skipped when another account already
// holds the address (`email` is unique); the login itself proceeds
// either way, keyed by (issuer, sub).
const displayEmail = email ? email.toLowerCase() : null;
if (
displayEmail &&
emailVerified === true &&
user.email?.toLowerCase() !== displayEmail
) {
const emailTaken = await prisma.user.findFirst({
where: {
email: { equals: displayEmail, mode: "insensitive" },
NOT: { id: user.id },
},
select: { id: true },
});
if (!emailTaken) {
user = await prisma.user.update({
where: { id: user.id },
data: { email: displayEmail },
});
}
}
} else {
// No stamped identity yet — both remaining paths (link-once,
// provision) bind this (issuer, sub) to an account keyed by email,
// so the claim must be explicitly `email_verified: true`. An ABSENT
// claim is a reject, not a benefit of the doubt: an IdP that does
// not assert verification cannot anchor a link that later gates a
// health record.
if (!email) {
annotate({ meta: { reason: "no_email" } });
return errorRedirect(req, "oidc_no_email");
}
if (emailVerified !== true) {
annotate({ meta: { reason: "email_unverified" } });
return errorRedirect(req, "oidc_email_unverified");
}
const byEmail = await prisma.user.findFirst({
where: { email: { equals: email, mode: "insensitive" } },
});
if (byEmail) {
// (2) Link ONCE. An account already pinned to a different IdP
// identity is never silently re-bound — a rebind would let a
// second IdP identity that acquires the same email capture the
// account. Audited so the operator can see the collision.
if (byEmail.oidcIssuer !== null || byEmail.oidcSub !== null) {
await auditLog("auth.oidc.link_conflict", {
userId: byEmail.id,
ipAddress: ip,
});
annotate({ meta: { reason: "identity_conflict" } });
return errorRedirect(req, "oidc_identity_conflict");
}
user = await prisma.user.update({
where: { id: byEmail.id },
data: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
});
await auditLog("auth.oidc.linked", {
userId: user.id,
ipAddress: ip,
});
} else {
// (3) Provision.
const userCount = await prisma.user.count();
let registrationEnabled = true;
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (settings && !settings.registrationEnabled && userCount > 0) {
registrationEnabled = false;
}
} catch {
// Table may not exist yet; allow provisioning (matches register/route.ts).
}
if (!registrationEnabled) {
annotate({ meta: { reason: "registration_disabled" } });
return errorRedirect(req, "oidc_registration_disabled");
}
const username = await deriveUniqueUsername(email, (candidate) =>
prisma.user
.findUnique({ where: { username: candidate } })
.then((u: unknown) => u !== null),
);
user = await prisma.user.create({
data: {
email: email.toLowerCase(),
username,
passwordHash: null,
role: userCount === 0 ? "ADMIN" : "USER",
// Same server-default resolution the register route uses — an
// SSO-provisioned account has no registration form to carry the
// browser-detected timezone.
timezone: await resolveServerDefaultTimezone(),
oidcIssuer: metadata.issuer,
oidcSub: identity.sub,
},
});
await auditLog("auth.oidc.provisioned", {
userId: user.id,
ipAddress: ip,
});
}
}
// Native second factor. OIDC is a DELEGATED factor: it proves the IdP
// authenticated someone, but it never substitutes for this app's own
// second factor. An account with a confirmed TOTP secret or a
// registered security key gets the same MFA challenge step password
// login gets — no session is minted here; the single-use ticket rides
// the handoff cookie to the login page, which renders the same
// challenge UI and completes at `/api/auth/mfa/verify`. (The
// trusted-device skip cannot apply on this path: its cookie is
// SameSite=Strict, which the browser withholds on an IdP-initiated
// redirect chain.)
const hasTotp = Boolean(user.totpConfirmedAt);
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: user.id },
});
const hasWebauthn = webauthnKeyCount > 0;
if (hasTotp || hasWebauthn) {
const challenge = await createMfaChallenge(user.id, "login");
const methods: ("totp" | "recovery" | "webauthn")[] = [];
if (hasTotp) methods.push("totp", "recovery");
if (hasWebauthn) methods.push("webauthn");
await auditLog("auth.mfa.challenge", {
userId: user.id,
ipAddress: ip,
details: { source: "login.oidc" },
});
annotate({
action: { name: "auth.mfa.challenge" },
meta: { mfa_required: true },
});
const loginUrl = new URL(LOGIN_ERROR_URL, req.url);
if (stored.next !== "/") {
loginUrl.searchParams.set("next", stored.next);
}
const response = NextResponse.redirect(loginUrl);
deleteStateCookie(response);
response.cookies.set(
OIDC_MFA_COOKIE,
JSON.stringify({ ticket: challenge.ticket, methods }),
{
// Not httpOnly by design — see the constant's doc comment.
httpOnly: false,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OIDC_MFA_TTL_MS / 1000),
path: OIDC_MFA_COOKIE_PATH,
},
);
return response;
}
const ua = req.headers.get("user-agent");
// v1.23 parity with password login — the admin-enforced-MFA hint cookie
// sends a single-factor account into forced enrollment after sign-in.
await syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
});
// The 5th argument stays null: OIDC is a delegated factor, so an SSO
// session must never satisfy `requireFreshMfa` step-up (MFA disable,
// encryption-key rotation, encrypted export, account deletion) — the
// same treatment a trusted-device login gets.
await createSession(
user.id,
user.onboardingCompletedAt === null,
ip,
ua,
null,
);
void recordSignInDevice({ userId: user.id, ip, userAgent: ua });
await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip });
const response = NextResponse.redirect(new URL(stored.next, req.url));
deleteStateCookie(response);
return response;
} catch (err) {
getEvent()?.setError(err);
annotate({ meta: { reason: "exchange_or_verify_failed" } });
return errorRedirect(req, "oidc_failed");
}
});
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => null,
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `enc(${s})`),
}));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
vi.mock("@/lib/auth/oidc", async () => {
// Keep the real `sanitizeOidcNextPath` — it's the security-relevant part
// of this route, and the tests below assert its output round-trips into
// the state cookie rather than re-deriving the same logic here.
const actual =
await vi.importActual<typeof import("@/lib/auth/oidc")>("@/lib/auth/oidc");
return {
getOidcConfig: vi.fn(),
discoverOidcMetadata: vi.fn(),
buildAuthorizationUrl: vi.fn(() => "https://idp.example.com/authorize?x=1"),
getOidcRedirectUri: () =>
"https://healthlog.example.com/api/auth/oidc/callback",
sanitizeOidcNextPath: actual.sanitizeOidcNextPath,
};
});
import { GET } from "../route";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { getOidcConfig, discoverOidcMetadata } from "@/lib/auth/oidc";
function makeRequest(path = "/api/auth/oidc/login"): NextRequest {
return new NextRequest(`http://localhost${path}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 10,
reset: 0,
ip: "1.2.3.4",
} as never);
});
describe("GET /api/auth/oidc/login", () => {
it("redirects to /auth/login?error=oidc_disabled when unconfigured", async () => {
vi.mocked(getOidcConfig).mockReturnValue(null);
const res = await GET(makeRequest());
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"/auth/login?error=oidc_disabled",
);
});
it("redirects to the rate-limit error page when throttled", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
reset: 0,
ip: "1.2.3.4",
} as never);
const res = await GET(makeRequest());
expect(res.headers.get("location")).toContain("error=oidc_rate_limited");
});
it("redirects to the IdP and sets the encrypted state cookie on success", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(discoverOidcMetadata).mockResolvedValue({
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
});
const res = await GET(makeRequest("/api/auth/oidc/login?next=/dashboard"));
expect(res.headers.get("location")).toBe(
"https://idp.example.com/authorize?x=1",
);
const cookie = res.cookies.get("oidc_auth_state");
expect(cookie?.value).toMatch(/^enc\(/);
expect(cookie?.httpOnly).toBe(true);
expect(cookie?.sameSite).toBe("lax");
});
it("neutralizes a backslash-normalization open-redirect payload in next", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(discoverOidcMetadata).mockResolvedValue({
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
});
const res = await GET(
makeRequest(
`/api/auth/oidc/login?next=${encodeURIComponent("/\\evil.com")}`,
),
);
const cookie = res.cookies.get("oidc_auth_state");
const stored = JSON.parse(
cookie!.value.replace(/^enc\(/, "").replace(/\)$/, ""),
);
expect(stored.next).toBe("/");
});
it("falls back to the login error page on discovery failure", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(discoverOidcMetadata).mockRejectedValue(new Error("boom"));
const res = await GET(makeRequest());
expect(res.headers.get("location")).toContain("error=oidc_failed");
});
});
+97
View File
@@ -0,0 +1,97 @@
import { randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
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";
/**
* Redirects the browser to the configured OIDC provider's authorization
* endpoint. Full-page navigation, not a fetch — every failure branch
* redirects to `/auth/login?error=...` rather than returning a JSON
* envelope, mirroring `src/app/api/withings/connect/route.ts`.
*/
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.login" } });
const config = getOidcConfig();
if (!config) {
return NextResponse.redirect(
new URL("/auth/login?error=oidc_disabled", req.url),
);
}
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:oidc:login",
10,
15 * 60 * 1000,
);
if (!rl.allowed) {
return NextResponse.redirect(
new URL("/auth/login?error=oidc_rate_limited", req.url),
);
}
let metadata;
try {
metadata = await discoverOidcMetadata(config);
} catch (err) {
getEvent()?.setError(err);
return NextResponse.redirect(
new URL("/auth/login?error=oidc_failed", req.url),
);
}
const state = randomBytes(32).toString("base64url");
const nonce = randomBytes(32).toString("base64url");
const codeVerifier = randomBytes(64).toString("base64url");
const codeChallenge = s256Challenge(codeVerifier);
const safeNext = sanitizeOidcNextPath(
req.nextUrl.searchParams.get("next"),
req.url,
);
const redirectUri = getOidcRedirectUri();
const authorizationUrl = buildAuthorizationUrl({
metadata,
config,
state,
nonce,
codeChallenge,
redirectUri,
});
const response = NextResponse.redirect(authorizationUrl);
const cookiePayload = encrypt(
JSON.stringify({ state, nonce, codeVerifier, next: safeNext }),
);
response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
// Cross-site top-level redirect back from the IdP, same reasoning as
// the session cookie and the Withings OAuth state cookie.
sameSite: "lax",
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
// Shared constant — the callback's delete must repeat this exact path
// (RFC 6265 keys cookies by name+domain+path).
path: OIDC_STATE_COOKIE_PATH,
});
return response;
});
+19
View File
@@ -0,0 +1,19 @@
import { apiSuccess } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { getOidcConfig, isOidcOnly } from "@/lib/auth/oidc";
export const dynamic = "force-dynamic";
/** Public — the login page reads this pre-session to decide whether to
* render the SSO button and whether to hide password/passkey login. Pure
* env reads; nothing here can throw. */
export const GET = apiHandler(async () => {
annotate({ action: { name: "auth.oidc.status" } });
const config = getOidcConfig();
return apiSuccess({
enabled: config !== null,
buttonLabel: config?.buttonLabel ?? null,
only: isOidcOnly(),
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/passkey", () => ({
createAuthenticationOptions: vi.fn().mockResolvedValue({
options: {},
challengeId: "ch-1",
}),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
import { POST } from "../route";
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-options", {
method: "POST",
});
}
beforeEach(() => {
vi.resetAllMocks();
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
vi.mocked(createAuthenticationOptions).mockResolvedValue({
options: {},
challengeId: "ch-1",
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
ip: "203.0.113.1",
} as never);
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
describe("POST /api/auth/passkey/login-options — OIDC_ONLY server-side enforcement", () => {
it("rejects before minting a WebAuthn challenge when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(createAuthenticationOptions).not.toHaveBeenCalled();
});
it("still allows passkey login-options when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(200);
});
});
@@ -1,11 +1,19 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: Request) => {
// OIDC_ONLY must block passkey login too, not just password login.
if (isOidcOnly()) {
return apiError("Passkey login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
apiToken: { create: vi.fn() },
refreshToken: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/passkey", () => ({
verifyAuthentication: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/mfa-enrollment", () => ({
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/login-alert", () => ({
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyAuthentication } from "@/lib/auth/passkey";
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ challengeId: "ch-1", credential: { id: "cred-1" } }),
});
}
beforeEach(() => {
vi.resetAllMocks();
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
username: "testuser",
email: "user@example.com",
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(verifyAuthentication).mockResolvedValue({
verification: { verified: true },
passkey: { userId: "user-1" },
} as never);
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
describe("POST /api/auth/passkey/login-verify — OIDC_ONLY server-side enforcement", () => {
it("rejects passkey login before touching WebAuthn verification when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(verifyAuthentication).not.toHaveBeenCalled();
});
it("still allows passkey login when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(200);
});
});
@@ -17,8 +17,17 @@ import {
isCookielessNativeCaller,
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block passkey login too, not just password login — a
// credential minted before SSO was mandated must not stay a live bypass.
if (isOidcOnly()) {
return apiError("Passkey login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
await ensureDbCompatibility();
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
@@ -1,7 +1,7 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/register.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -118,3 +118,50 @@ describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
async function postPathThrough(body: unknown): Promise<Response> {
return POST(postReq(body));
}
describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () => {
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
it("rejects self-registration before any DB lookup when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await postPathThrough({
email: "new@example.com",
username: "newuser",
password: "a-very-strong-password-123",
});
expect(res.status).toBe(403);
});
it("still allows registration when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await postPathThrough({ email: "not-an-email", username: "x" });
// Falls through to normal validation (422 here) rather than 403 — a
// half-set OIDC group must never lock everyone out.
expect(res.status).toBe(422);
});
});
+10
View File
@@ -26,8 +26,18 @@ import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block self-registration too — otherwise anyone can
// self-provision a fresh password account and bypass the operator's
// SSO-only policy outright, invite token or not.
if (isOidcOnly()) {
return apiError("Registration is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
// is misconfigured; otherwise byte-equivalent per-IP semantics.
const rl = await checkAuthSurfaceRateLimit(
@@ -33,6 +33,8 @@ const mocks = vi.hoisted(() => ({
importJobCreate: vi.fn(),
importJobFindFirst: vi.fn().mockResolvedValue(null),
importJobUpdate: vi.fn(),
streamToDisk: vi.fn(),
unlink: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -43,6 +45,15 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: mocks.getGlobalBoss,
}));
vi.mock("@/lib/multipart/stream-to-disk", () => ({
streamMultipartToDisk: mocks.streamToDisk,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, unlink: mocks.unlink };
});
vi.mock("@/lib/db", () => ({
prisma: {
importJob: {
@@ -61,6 +72,8 @@ const SESSION_OK = {
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const STAGED_PATH = "/tmp/healthlog-apple-health-import-abc.bin";
beforeEach(() => {
vi.resetAllMocks();
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
@@ -69,8 +82,24 @@ beforeEach(() => {
mocks.importJobCreate.mockResolvedValue({ id: "ij-1" });
mocks.importJobFindFirst.mockResolvedValue(null);
mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" });
mocks.streamToDisk.mockResolvedValue({
filePath: STAGED_PATH,
bytes: 100,
sha256: "deadbeef",
originalFilename: "export.zip",
textFields: {},
});
mocks.unlink.mockResolvedValue(undefined);
});
function multipartReq(): NextRequest {
return new NextRequest("http://localhost/api/import/apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
}
describe("POST /api/import/apple-health-export", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -119,3 +148,53 @@ describe("POST /api/import/apple-health-export", () => {
expect(res.status).toBe(429);
});
});
describe("POST /api/import/apple-health-export — dedup by content hash (issue #486)", () => {
it("re-stages and enqueues when the only prior job for the same bytes is failed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// The DB holds a FAILED prior job for this sha. The dedup query
// excludes failed rows, so it returns null and the upload must fall
// through to a fresh stage + boss.send instead of replaying the dead
// job's id/failureReason forever.
mocks.importJobFindFirst.mockImplementation(async (args: unknown) => {
const where = (args as { where?: { status?: { not?: string } } }).where;
if (where?.status?.not === "failed") return null;
return { id: "old-failed-job", status: "failed" };
});
const res = await POST(multipartReq());
expect(res.status).toBe(202);
const body = await res.json();
expect(body.data.status).toBe("queued");
expect(body.data.idempotent).toBeUndefined();
expect(body.data.jobId).not.toBe("old-failed-job");
expect(mocks.bossSend).toHaveBeenCalledTimes(1);
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
// The dedup lookup must exclude failed rows.
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: { not: "failed" } }),
}),
);
// A fresh job was staged — nothing to unlink on this path.
expect(mocks.unlink).not.toHaveBeenCalled();
});
it("short-circuits to a viable prior job and unlinks the redundant upload", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
mocks.importJobFindFirst.mockResolvedValue({
id: "live-job",
status: "parsing",
});
const res = await POST(multipartReq());
expect(res.status).toBe(202);
const body = await res.json();
expect(body.data.idempotent).toBe(true);
expect(body.data.jobId).toBe("live-job");
expect(mocks.bossSend).not.toHaveBeenCalled();
expect(mocks.importJobCreate).not.toHaveBeenCalled();
// The freshly staged upload is redundant and must be cleaned up.
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
});
});
@@ -12,7 +12,10 @@
* the bytes) short-circuits to the previous `ImportJob` id without
* re-queueing — the iOS client cannot reliably emit a stable
* `Idempotency-Key` for a 1 GB file, so we hash the bytes and dedup
* on content.
* on content. The dedup only covers still-viable jobs (queued,
* in-flight, or already `done`); a prior `failed` job is NOT matched,
* so the same export can always be retried with the same bytes
* (issue #486 — a failed row must never tombstone its own hash).
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.3.
*/
@@ -27,9 +30,11 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_QUEUE,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
import { unlink } from "node:fs/promises";
export const dynamic = "force-dynamic";
@@ -103,15 +108,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
// Content-hash idempotency: a re-upload of the same bytes resolves
// to the previous ImportJob row instead of re-queueing.
// to the previous ImportJob row instead of re-queueing — but ONLY
// when that prior job is still viable (queued / in-flight / done). A
// `failed` prior job must NOT short-circuit (issue #486): matching it
// returned its stale failureReason forever and made retrying the same
// export impossible. Excluding `failed` lets the same bytes fall
// through to a fresh stage + enqueue.
const existing = await prisma.importJob.findFirst({
where: {
userId: user.id,
uploadSha256: uploaded.sha256,
status: { not: "failed" },
},
orderBy: { startedAt: "desc" },
});
if (existing) {
// The existing viable job owns the canonical bytes; the upload we
// just streamed to `/tmp` is redundant. Unlink it so a deduped
// re-upload does not leak a gigabyte-scale staging file.
await unlink(uploaded.filePath).catch(() => {});
annotate({ meta: { idempotent_hit: true, job_id: existing.id } });
return apiSuccess(
{
@@ -148,7 +163,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
uploadBytes: uploaded.bytes,
enqueuedAt: new Date().toISOString(),
};
const bossJobId = await boss.send(APPLE_HEALTH_IMPORT_QUEUE, payload);
// No retries + wide expiration: the staged upload is consumed by the
// first run, so a redelivery could only fail on the deleted `/tmp`
// file and mask the real outcome (see the worker's send-options doc).
const bossJobId = await boss.send(
APPLE_HEALTH_IMPORT_QUEUE,
payload,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
);
await prisma.importJob.update({
where: { id: importJob.id },
@@ -405,11 +405,16 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
it("does NOT touch the cache when serving from the 24h DB cache", async () => {
// Cached path: route returns early without touching the LLM or the
// cache write.
// cache write. v1.28.30 — the short-circuit requires the cached
// payload to CARRY a briefing (a briefingless cache is stale for a
// briefing-expecting caller), so the fixture carries one.
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({ changed: "still fresh" }),
insightsCachedText: JSON.stringify({
changed: "still fresh",
dailyBriefing: { paragraph: "ok", keyFindings: [] },
}),
locale: "en",
} as never);
@@ -445,7 +450,10 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({ changed: "still fresh" }),
insightsCachedText: JSON.stringify({
changed: "still fresh",
dailyBriefing: { paragraph: "ok", keyFindings: [] },
}),
locale: "en",
} as never);
@@ -467,6 +475,99 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
});
});
// v1.28.30 — the "no briefing today" chain: a failed nightly warm left a
// fresh-but-briefingless cache inside the 24 h window, and the POST's
// unconditional short-circuit served it to every regenerate attempt
// (`cached: true` three times in prod, zero generations, a full day
// without a briefing). The short-circuit now only holds when the cached
// payload carries a briefing OR the account has no usable provider.
describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)", () => {
function mockBriefinglessFreshCache() {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({
changed: "fresh but no briefing",
dailyBriefing: null,
}),
insightsExcludeMetrics: [],
locale: "en",
} as never);
}
it("POST without force + fresh cache WITH briefing → served cached", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({
dailyBriefing: { paragraph: "today", keyFindings: [] },
}),
locale: "en",
} as never);
const res = await POST(jsonRequest({}) as never);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("POST without force + fresh cache WITHOUT briefing + provider → regenerates", async () => {
makeWorkingProvider();
mockBriefinglessFreshCache();
const res = await POST(jsonRequest({}) as never);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
// The stale-for-briefing cache was bypassed: a real generation ran and
// wrote a fresh cache row.
expect(body.data.cached).toBe(false);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
it("POST without force + fresh cache WITHOUT briefing + NO provider → serves cached (regeneration is futile)", async () => {
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
mockBriefinglessFreshCache();
const res = await POST(jsonRequest({}) as never);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("degrades to the cached payload instead of 429 when the briefingless fall-through is rate-limited", async () => {
makeWorkingProvider();
mockBriefinglessFreshCache();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
const res = await POST(jsonRequest({}) as never);
// A POST-as-read caller never used to see a 429 on a fresh cache;
// exhausting the quota falls back to the old cached-serve behaviour.
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("explicit force keeps the honest 429 when rate-limited", async () => {
makeWorkingProvider();
mockBriefinglessFreshCache();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(429);
});
});
// v1.4.36 QA H1 — when both the raw-mode extraction AND the aggregated
// retry cross the 5 MB ceiling, the route used to throw uncaught → 500.
// It now wraps the retry, downgrades further via the exclude filter,
@@ -619,6 +720,66 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
expect(body.data.revalidating).toBe(false);
});
// v1.28.30 — a briefing the grounding gate withheld used to be visible
// only on the transient POST response; every later read showed a generic
// "no briefing yet". The marker written after the cache write now
// surfaces the omission on the read path, WITHOUT reading as a failure.
it("surfaces briefingOmittedReason from a briefing-ungrounded marker (not as a failure)", async () => {
const cachedAt = new Date(Date.now() - 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: cachedAt,
insightsCachedText: JSON.stringify({ dailyBriefing: null }),
locale: "en",
} as never);
// Marker newer than the last successful generation (written right
// after the cache write on the strip path).
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
createdAt: new Date(cachedAt.getTime() + 1000),
details: JSON.stringify({ reason: "briefing-ungrounded" }),
} as never);
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/insights/generate"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
briefingOmittedReason: string | null;
generationFailed: boolean;
generationFailureClass: string | null;
};
};
expect(body.data.briefingOmittedReason).toBe("ungrounded");
// The generation SUCCEEDED (the briefing was withheld) — the card must
// render "withheld", not "couldn't generate".
expect(body.data.generationFailed).toBe(false);
expect(body.data.generationFailureClass).toBeNull();
});
it("still reports generationFailed for a genuine failure marker", async () => {
const cachedAt = new Date(Date.now() - 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: cachedAt,
insightsCachedText: JSON.stringify({
dailyBriefing: { paragraph: "held", keyFindings: [] },
}),
locale: "en",
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
createdAt: new Date(cachedAt.getTime() + 1000),
details: JSON.stringify({ reason: "provider-error", httpStatus: 503 }),
} as never);
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/insights/generate"),
);
const body = (await res.json()) as {
data: { briefingOmittedReason: string | null; generationFailed: boolean };
};
expect(body.data.generationFailed).toBe(true);
expect(body.data.briefingOmittedReason).toBeNull();
});
it("reports hasProvider: true on a normal cached read", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: new Date(),
+93 -10
View File
@@ -81,6 +81,7 @@ import {
buildComparisonSnapshotForUser,
enqueueStatusRefillForUser,
} from "@/lib/insights/comprehensive-generate";
import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload";
export const dynamic = "force-dynamic";
@@ -168,10 +169,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
userId,
since: cachedAt,
});
const generationFailed = briefingFailure !== null;
// v1.28.30 — a `briefing-ungrounded` marker is NOT a failure: the
// generation succeeded but the grounding gate withheld the briefing
// (marker written after the cache write, so it survives the `since`
// filter). Surface it as the persistent omission signal — the card's
// "briefing withheld" state used to exist only on the transient POST
// response, so a nightly strip read as a silent generic empty card.
const briefingOmittedReason: "ungrounded" | null =
briefingFailure?.reason === "briefing-ungrounded" ? "ungrounded" : null;
const generationFailed =
briefingFailure !== null && briefingOmittedReason === null;
// v1.25.3 — the failure class lets the empty state point its hint at the
// right lever (raise the response timeout vs re-check the provider).
const generationFailureClass = briefingFailure?.failureClass ?? null;
const generationFailureClass = generationFailed
? (briefingFailure?.failureClass ?? null)
: null;
// Read-only: never block on the provider. Warm out of band only when the
// cached briefing is stale / missing AND a provider is configured (a
@@ -222,6 +234,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.25.3 — coarse failure class for the empty-state hint (null when
// the last attempt succeeded).
generationFailureClass,
// v1.28.30 — persistent grounding-omission signal (see above), so a
// nightly-stripped briefing renders "withheld", not "no briefing yet".
briefingOmittedReason,
});
} catch {
// Invalid cache row — fall through to the empty payload below. The
@@ -246,6 +261,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
generationFailed,
// v1.25.3 — coarse failure class for the empty-state hint.
generationFailureClass,
// v1.28.30 — see the cached branch above.
briefingOmittedReason,
});
});
@@ -301,6 +318,24 @@ export const POST = apiHandler((request: NextRequest) =>
if (jsonError) return jsonError;
const forceRefresh = body.force === true;
// v1.28.30 — a fresh cache WITHOUT a briefing must not satisfy a
// briefing-expecting caller: after a failed nightly warm the cached
// payload can sit inside the 24 h window all day, and the old
// unconditional short-circuit served it to every POST (three
// regenerate attempts, three `cached: true` responses, zero
// generations). The short-circuit now holds only when the cached
// payload actually carries a briefing OR the account has no usable
// provider (regenerating would be futile — serve what exists). The
// read-only GET is untouched: it never generates and its out-of-band
// revalidation behaviour stays as-is. When the briefingless fall-
// through is rate-limited below, the cached payload is served after
// all so a POST-as-read client degrades to the old behaviour instead
// of a 429.
let briefinglessCacheFallback: {
cached: unknown;
legacyPayload: boolean;
cachedAt: Date;
} | null = null;
if (
!forceRefresh &&
dbUser?.insightsCachedAt &&
@@ -314,15 +349,33 @@ export const POST = apiHandler((request: NextRequest) =>
// We don't auto-regenerate — that would burn rate-limit tokens
// on a cache-hit silently. User-initiated only.
const legacyPayload = isLegacyInsightPayload(cached);
annotate({
action: { name: "insights.generate" },
meta: { cached: true, legacyPayload },
});
return apiSuccess({
insights: cached,
cached: true,
cachedAt: dbUser.insightsCachedAt,
const carriesBriefing = cachedPayloadCarriesBriefing(
dbUser.insightsCachedText,
);
if (carriesBriefing || !(await hasUsableStatusProvider(userId))) {
annotate({
action: { name: "insights.generate" },
meta: {
cached: true,
legacyPayload,
briefingless: !carriesBriefing,
},
});
return apiSuccess({
insights: cached,
cached: true,
cachedAt: dbUser.insightsCachedAt,
legacyPayload,
});
}
briefinglessCacheFallback = {
cached,
legacyPayload,
cachedAt: dbUser.insightsCachedAt,
};
annotate({
action: { name: "insights.generate.briefingless_cache_bypassed" },
meta: { locale },
});
} catch {
// Invalid cache, regenerate
@@ -346,6 +399,22 @@ export const POST = apiHandler((request: NextRequest) =>
60 * 60 * 1000,
);
if (!rl.allowed) {
// v1.28.30 — a POST-as-read caller whose fresh-but-briefingless cache
// fell through to regeneration degrades to the cached payload when
// the hourly quota is exhausted, instead of surfacing a 429 it never
// used to see. Explicit `force` keeps the honest 429.
if (briefinglessCacheFallback !== null) {
annotate({
action: { name: "insights.generate" },
meta: { cached: true, rate_limited_fallback: true },
});
return apiSuccess({
insights: briefinglessCacheFallback.cached,
cached: true,
cachedAt: briefinglessCacheFallback.cachedAt,
legacyPayload: briefinglessCacheFallback.legacyPayload,
});
}
return apiError(`Maximum ${limit} insight generations per hour.`, 429);
}
@@ -855,6 +924,20 @@ export const POST = apiHandler((request: NextRequest) =>
},
});
// v1.28.30 — persist the grounding omission (marker AFTER the cache
// write so it is newer than `insightsCachedAt`). The transient
// `briefingOmittedReason` on this response only reached the caller that
// forced the regenerate; every later read showed a generic "no briefing
// yet". The dated marker lets the GET surface the same honest
// "briefing withheld" state until the next successful generation.
if (briefingOmittedReason === "ungrounded") {
void recordBriefingFailure({
userId,
reason: "briefing-ungrounded",
locale,
});
}
// v1.16.8 — no blanket per-status eviction here any more (nuking ~45
// cache rows per manual regenerate deleted every hash baseline and was
// the source of the post-regenerate card-regeneration storm). Instead,
@@ -0,0 +1,146 @@
/**
* v1.28 — `GET /api/nutrients` window-summary contract tests.
*
* Pins the read shape the settings card consumes: catalog-ordered
* per-nutrient summaries (latest day + total, days-with-data), the
* module gate, and the `days` query validation.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
nutrientIntakeDay: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { apiError } from "@/lib/api-response";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function getReq(query = ""): NextRequest {
return new NextRequest(`http://localhost/api/nutrients${query}`);
}
interface OverviewResponse {
data: {
windowDays: number;
nutrients: Array<{
nutrient: string;
unit: string;
latestDay: string;
latestAmount: number;
daysWithData: number;
}>;
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
});
describe("GET /api/nutrients", () => {
it("refuses with the 403 module.disabled envelope when the module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "nutrients" is not enabled', 403, {
errorCode: "module.disabled",
module: "nutrients",
}),
});
const res = await GET(getReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("module.disabled");
expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled();
});
it("rejects an out-of-range days value with 422", async () => {
for (const bad of ["0", "366", "abc"]) {
const res = await GET(getReq(`?days=${bad}`));
expect(res.status, `days=${bad}`).toBe(422);
}
});
it("defaults to a 14-day window and returns an empty list for a fresh account", async () => {
const res = await GET(getReq());
expect(res.status).toBe(200);
const body = (await res.json()) as OverviewResponse;
expect(body.data).toEqual({ windowDays: 14, nutrients: [] });
});
it("folds rows into catalog-ordered summaries with latest day/amount and day counts", async () => {
// Rows arrive nutrient ASC, day DESC (the route's orderBy) — the
// fold takes the first row per nutrient as the latest.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{ nutrient: "caffeine", unit: "mg", day: "2026-07-13", amount: 310 },
{ nutrient: "caffeine", unit: "mg", day: "2026-07-12", amount: 250 },
{ nutrient: "caffeine", unit: "mg", day: "2026-07-11", amount: 400 },
{ nutrient: "vitamin_d", unit: "ug", day: "2026-07-13", amount: 22.5 },
{ nutrient: "vitamin_d", unit: "ug", day: "2026-07-10", amount: 20 },
] as never);
const res = await GET(getReq("?days=7"));
const body = (await res.json()) as OverviewResponse;
expect(body.data.windowDays).toBe(7);
// Catalog order: vitamin_d (vitamins block) before caffeine (tail).
expect(body.data.nutrients).toEqual([
{
nutrient: "vitamin_d",
unit: "ug",
latestDay: "2026-07-13",
latestAmount: 22.5,
daysWithData: 2,
},
{
nutrient: "caffeine",
unit: "mg",
latestDay: "2026-07-13",
latestAmount: 310,
daysWithData: 3,
},
]);
});
it("scopes the query to the authenticated user and the window floor", async () => {
await GET(getReq("?days=14"));
expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
day: { gte: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
}),
}),
);
});
});
@@ -0,0 +1,354 @@
/**
* v1.28 — `POST /api/nutrients/batch` contract tests.
*
* Pins the ingest posture: module gate first (403 `module.disabled`),
* per-entry skip guards (unit mismatch / plausibility / day key), the
* insert-vs-update statuses off the composite-PK probe, and the batch
* envelope errors (`too_large`, multi-issue `invalid`).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
nutrientIntakeDay: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { apiError } from "@/lib/api-response";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/nutrients/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
/** Yesterday's UTC day key — always a valid, in-window day. */
function recentDay(offsetDays = 1): string {
return new Date(Date.now() - offsetDays * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
}
interface BatchResponse {
data: {
processed: number;
inserted: number;
updated: number;
skipped: Array<{ index: number; reason: string }>;
entries: Array<{ index: number; status: string; reason?: string }>;
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(null);
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({} as never);
});
describe("POST /api/nutrients/batch — module gate", () => {
it("refuses ingest with the 403 module.disabled envelope when the module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "nutrients" is not enabled', 403, {
errorCode: "module.disabled",
module: "nutrients",
}),
});
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "vitamin_d", unit: "ug", amount: 20 },
],
}),
);
expect(res.status).toBe(403);
const body = (await res.json()) as {
data: null;
meta?: { errorCode?: string; module?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("module.disabled");
expect(body.meta?.module).toBe("nutrients");
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
});
describe("POST /api/nutrients/batch — envelope errors", () => {
it("rejects a 501-entry batch with 422 nutrient.batch.too_large before Zod", async () => {
const entries = Array.from({ length: 501 }, () => ({
day: recentDay(),
nutrient: "vitamin_c",
unit: "mg",
amount: 100,
}));
const res = await POST(postReq({ entries }));
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("nutrient.batch.too_large");
});
it("surfaces multiple simultaneous Zod issues as one 422 nutrient.batch.invalid", async () => {
const res = await POST(
postReq({
entries: [
{ day: "not-a-day", nutrient: "vitamin_d", unit: "ug", amount: 20 },
{ day: recentDay(), nutrient: "kryptonite", unit: "mg", amount: 1 },
{ day: recentDay(), nutrient: "iron", unit: "mg", amount: -5 },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: unknown[] };
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("nutrient.batch.invalid");
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("returns 429 when the per-user rate bucket is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "water", unit: "ml", amount: 1500 },
],
}),
);
expect(res.status).toBe(429);
});
});
describe("POST /api/nutrients/batch — per-entry skip guards", () => {
it("skips a unit mismatch (µg posted as mg) without touching the store", async () => {
const res = await POST(
postReq({
entries: [
// vitamin_d is canonically µg; posting mg is the 1000× hazard.
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as BatchResponse;
expect(body.data.entries[0]).toEqual({
index: 0,
status: "skipped",
reason: "unit_mismatch",
});
expect(body.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]);
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("skips a value above the plausibility cap", async () => {
const res = await POST(
postReq({
entries: [
// caffeine cap is 2000 mg.
{ day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 2001 },
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.entries[0].status).toBe("skipped");
expect(body.data.entries[0].reason).toBe("value_out_of_range");
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("skips an impossible calendar day and a far-future day as day_invalid", async () => {
const res = await POST(
postReq({
entries: [
// Regex-shaped but not a real date.
{ day: "2026-02-31", nutrient: "iron", unit: "mg", amount: 10 },
// Beyond tomorrow in every IANA timezone.
{ day: "2999-01-01", nutrient: "iron", unit: "mg", amount: 10 },
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.entries.map((e) => e.reason)).toEqual([
"day_invalid",
"day_invalid",
]);
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("a skipped entry never fails the batch — siblings still land", async () => {
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
{ day: recentDay(), nutrient: "magnesium", unit: "mg", amount: 300 },
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.processed).toBe(2);
expect(body.data.inserted).toBe(1);
expect(body.data.skipped).toHaveLength(1);
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/nutrients/batch — upsert semantics", () => {
it("reports inserted for a fresh (day, nutrient) and writes the catalog's canonical unit", async () => {
const day = recentDay();
const res = await POST(
postReq({
entries: [
{
day,
nutrient: "vitamin_d",
unit: "ug",
amount: 22.5,
externalSourceVersion: "yazio-9.9",
},
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.inserted).toBe(1);
expect(body.data.updated).toBe(0);
expect(body.data.entries[0].status).toBe("inserted");
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({
where: {
userId_day_nutrient: {
userId: "user-1",
day,
nutrient: "vitamin_d",
},
},
create: {
userId: "user-1",
day,
nutrient: "vitamin_d",
amount: 22.5,
unit: "ug",
externalSourceVersion: "yazio-9.9",
},
update: {
amount: 22.5,
unit: "ug",
externalSourceVersion: "yazio-9.9",
},
});
});
it("reports updated (never duplicate) when the composite key already exists", async () => {
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue({
userId: "user-1",
} as never);
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 310 },
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.inserted).toBe(0);
expect(body.data.updated).toBe(1);
expect(body.data.entries[0].status).toBe("updated");
expect(
body.data.entries.some((e) => e.status === ("duplicate" as string)),
).toBe(false);
});
it("writes the audit-ledger breadcrumb only when at least one row landed", async () => {
await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
],
}),
);
expect(auditLog).not.toHaveBeenCalled();
await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "water", unit: "ml", amount: 1800 },
],
}),
);
expect(auditLog).toHaveBeenCalledWith(
"nutrient.batch.ingest",
expect.objectContaining({ userId: "user-1" }),
);
});
it("a thrown upsert marks only that entry skipped upsert_failed", async () => {
vi.mocked(prisma.nutrientIntakeDay.upsert)
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce({} as never);
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
{ day: recentDay(), nutrient: "iron", unit: "mg", amount: 12 },
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.entries[0].status).toBe("skipped");
expect(body.data.entries[1].status).toBe("inserted");
expect(body.data.inserted).toBe(1);
});
});
+248
View File
@@ -0,0 +1,248 @@
/**
* `POST /api/nutrients/batch` — micronutrient day-total ingest (v1.28).
*
* iOS posts daily totals for the closed 26-code catalog (vitamins,
* minerals, water, caffeine) computed via `HKStatisticsCollectionQuery`
* day-anchored cumulative sums; `day` arrives as YYYY-MM-DD in the
* user's IANA timezone (the `stats:` day-key contract — the server
* trusts the string after regex + calendar sanity, no re-derivation).
*
* Upsert key = the composite PK (userId, day, nutrient). Re-post
* replaces amount / unit / externalSourceVersion — last-writer-wins,
* the day-total contract; per-entry status is `updated` then, never
* `duplicate`. Per-entry failures come back `skipped` with a reason
* (`unit_mismatch` | `value_out_of_range` | `day_invalid` |
* `upsert_failed`), never a batch failure — the measurements-batch
* posture.
*
* Module gate FIRST: the opt-in `nutrients` module (default off)
* refuses ingest with the 403 `module.disabled` envelope, so a phone
* whose user never opted in cannot land rows server-side
* (refuse-ingest posture, mirroring mental health — not surface-only).
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import {
MAX_NUTRIENT_ENTRIES_PER_BATCH,
nutrientBatchSchema,
} from "@/lib/validations/nutrients";
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
/**
* Calendar sanity for an already regex-shaped YYYY-MM-DD key: reject
* impossible dates (2026-02-31) via a UTC round-trip.
*/
function isRealCalendarDay(day: string): boolean {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
return (
dt.getUTCFullYear() === y &&
dt.getUTCMonth() === m - 1 &&
dt.getUTCDate() === d
);
}
/**
* Upper day bound with timezone slack: "tomorrow" in the most-ahead
* IANA zone (UTC+14) is at most the UTC calendar date + 2 days, so a
* key beyond that cannot be a legitimate local day and is corruption.
*/
function maxAcceptableDay(now: Date): string {
const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
return limit.toISOString().slice(0, 10);
}
type EntryStatus = "inserted" | "updated" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Module gate first: a disabled module must refuse before any body
// work, so iOS can branch on the 403 `module.disabled` errorCode and
// stop syncing rather than retry.
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`nutrients:batch:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many batch submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 1024 * 1024,
});
if (jsonError) return jsonError;
if (
typeof rawBody === "object" &&
rawBody !== null &&
"entries" in rawBody &&
Array.isArray((rawBody as { entries: unknown }).entries) &&
(rawBody as { entries: unknown[] }).entries.length >
MAX_NUTRIENT_ENTRIES_PER_BATCH
) {
return apiError(
`Batch exceeds the ${MAX_NUTRIENT_ENTRIES_PER_BATCH}-entry limit`,
422,
{ errorCode: "nutrient.batch.too_large" },
);
}
const parsed = nutrientBatchSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "nutrient.batch.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "nutrient.batch.invalid",
});
}
const { entries } = parsed.data;
const dayCeiling = maxAcceptableDay(new Date());
const results: EntryResult[] = [];
const skipped: Array<{ index: number; reason: string }> = [];
let inserted = 0;
let updated = 0;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const definition = NUTRIENT_CATALOG[entry.nutrient];
// The wire carries the unit deliberately: a µg/mg confusion is a
// silent 1000× corruption; anything but the catalog's canonical
// unit skips the entry rather than converting.
if (entry.unit !== definition.unit) {
skipped.push({ index: i, reason: "unit_mismatch" });
results.push({ index: i, status: "skipped", reason: "unit_mismatch" });
continue;
}
if (entry.amount > definition.plausibleDailyMax) {
skipped.push({ index: i, reason: "value_out_of_range" });
results.push({
index: i,
status: "skipped",
reason: "value_out_of_range",
});
continue;
}
if (!isRealCalendarDay(entry.day) || entry.day > dayCeiling) {
skipped.push({ index: i, reason: "day_invalid" });
results.push({ index: i, status: "skipped", reason: "day_invalid" });
continue;
}
try {
const key = {
userId_day_nutrient: {
userId: user.id,
day: entry.day,
nutrient: entry.nutrient,
},
};
// Probe-then-upsert so the response reliably distinguishes
// `inserted` from `updated` (the mood-bulk pattern; two round
// trips per entry is fine under the 500-entry cap).
const existing = await prisma.nutrientIntakeDay.findUnique({
where: key,
select: { userId: true },
});
await prisma.nutrientIntakeDay.upsert({
where: key,
create: {
userId: user.id,
day: entry.day,
nutrient: entry.nutrient,
amount: entry.amount,
unit: definition.unit,
externalSourceVersion: entry.externalSourceVersion ?? null,
},
update: {
// Last-writer-wins on the day total: iOS re-posts the current
// and previous local day on every sync as the totals grow.
amount: entry.amount,
unit: definition.unit,
externalSourceVersion: entry.externalSourceVersion ?? null,
},
});
if (existing) {
updated += 1;
results.push({ index: i, status: "updated" });
} else {
inserted += 1;
results.push({ index: i, status: "inserted" });
}
} catch (err: unknown) {
const reason =
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
}
}
if (inserted + updated > 0) {
await auditLog("nutrient.batch.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
processed: entries.length,
inserted,
updated,
skipped: skipped.length,
},
});
}
annotate({
action: { name: "nutrient.batch.ingest" },
meta: {
processed: entries.length,
inserted,
updated,
skipped: skipped.length,
},
});
return apiSuccess({
processed: entries.length,
inserted,
updated,
skipped,
entries: results,
});
}
+97
View File
@@ -0,0 +1,97 @@
/**
* `GET /api/nutrients?days=N` — synced-nutrient window summary (v1.28).
*
* Feeds the read-only settings card (Settings → Sources): per nutrient
* with data inside the window, the latest synced day + total and the
* count of days carrying data — the smallest honest surface for "what
* does the server hold". Catalog order, no pagination (≤ 26 rows).
* Module-gated like the ingest path: 403 `module.disabled` when the
* opt-in `nutrients` module is off. `userId` is narrowed from auth.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NUTRIENT_CODES, isNutrientCode } from "@/lib/nutrients/catalog";
import { nutrientOverviewQuerySchema } from "@/lib/validations/nutrients";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
const parsed = nutrientOverviewQuerySchema.safeParse({
days: request.nextUrl.searchParams.get("days") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "nutrient.read.invalid",
});
}
const { days } = parsed.data;
// Window floor as a UTC day key. The stored `day` is a local-timezone
// key, so the UTC floor is off by at most one calendar day at the
// window edge — fine for a "last N days" summary card.
const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId: user.id, day: { gte: since } },
orderBy: [{ nutrient: "asc" }, { day: "desc" }],
select: { nutrient: true, unit: true, day: true, amount: true },
});
// Fold to one summary row per nutrient. Rows arrive day-DESC inside
// each nutrient, so the first row seen per code is the latest.
const byCode = new Map<
string,
{ unit: string; latestDay: string; latestAmount: number; days: number }
>();
for (const row of rows) {
const existing = byCode.get(row.nutrient);
if (existing) {
existing.days += 1;
} else {
byCode.set(row.nutrient, {
unit: row.unit,
latestDay: row.day,
latestAmount: row.amount,
days: 1,
});
}
}
// Catalog order; a stored code that ever fell out of the catalog is
// dropped from the card rather than crashing the label lookup.
const nutrients = NUTRIENT_CODES.filter(
(code) => isNutrientCode(code) && byCode.has(code),
).map((code) => {
const summary = byCode.get(code)!;
return {
nutrient: code,
unit: summary.unit,
latestDay: summary.latestDay,
latestAmount: summary.latestAmount,
daysWithData: summary.days,
};
});
annotate({
action: { name: "nutrient.intake.read" },
meta: {
window_days: days,
nutrient_count: nutrients.length,
row_count: rows.length,
},
});
return apiSuccess({ windowDays: days, nutrients });
});
+189 -93
View File
@@ -3,7 +3,7 @@
import { useEffect, useId, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Lock, Loader2 } from "lucide-react";
import { KeyRound, Lock, Loader2, Shield } from "lucide-react";
import { Logo } from "@/components/ui/logo";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -20,9 +20,11 @@ import {
apiPost,
} from "@/lib/api/api-fetch";
import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step";
import { OIDC_MFA_COOKIE, OIDC_MFA_COOKIE_PATH } from "@/lib/auth/oidc-cookie";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import { sanitizeSameOriginPath } from "@/lib/url-safety";
export default function LoginPage() {
const router = useRouter();
@@ -32,7 +34,18 @@ export default function LoginPage() {
const [mode, setMode] = useState<"passkey" | "password">("passkey");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(() => {
const oidcError = searchParams.get("error");
if (!oidcError) return null;
const key: Record<string, string> = {
oidc_denied: "auth.oidc.errorDenied",
oidc_no_email: "auth.oidc.errorNoEmail",
oidc_email_unverified: "auth.oidc.errorEmailUnverified",
oidc_identity_conflict: "auth.oidc.errorIdentityConflict",
oidc_registration_disabled: "auth.oidc.errorRegistrationDisabled",
};
return t(key[oidcError] ?? "auth.oidc.errorFailed");
});
const [loading, setLoading] = useState(false);
// v1.23 — second step of login when the password response carries
// `meta.mfaRequired`. Holds the opaque ticket + the factors the account can
@@ -66,14 +79,32 @@ export default function LoginPage() {
staleTime: 60 * 1000,
});
const { data: oidcStatus } = useQuery({
queryKey: queryKeys.authOidcStatus(),
queryFn: async () => {
try {
return await apiGet<{
enabled?: boolean;
buttonLabel?: string | null;
only?: boolean;
}>("/api/auth/oidc/status", { cache: "no-store" });
} catch {
return { enabled: false, buttonLabel: null, only: false };
}
},
staleTime: 60 * 1000,
});
function handleOidcLogin() {
const params = new URLSearchParams({ next: getRedirectTarget() });
window.location.href = `/api/auth/oidc/login?${params}`;
}
function getRedirectTarget(): string {
const next = searchParams.get("next");
if (!next) return "/";
// Prevent open redirects: only allow local absolute paths.
if (next.startsWith("/") && !next.startsWith("//")) {
return next;
}
return "/";
return sanitizeSameOriginPath(
searchParams.get("next"),
window.location.href,
);
}
// v1.16.6 first-load waterfall fix — the session cookie exists the
@@ -188,11 +219,50 @@ export default function LoginPage() {
}
}
// OIDC → second-factor handoff. The OAuth callback cannot render UI: for
// a 2FA-enrolled account it mints the same single-use MFA ticket password
// login uses and parks it in a short-lived, login-page-scoped cookie
// (`oidc-cookie.ts` documents why it is readable here). Pick it up once,
// clear it, and enter the exact same MfaLoginStep the password flow uses —
// `next` continuation rides the query string as usual.
useEffect(() => {
const raw = document.cookie
.split("; ")
.find((c) => c.startsWith(`${OIDC_MFA_COOKIE}=`))
?.slice(OIDC_MFA_COOKIE.length + 1);
if (!raw) return;
document.cookie = `${OIDC_MFA_COOKIE}=; path=${OIDC_MFA_COOKIE_PATH}; max-age=0`;
try {
const parsed = JSON.parse(decodeURIComponent(raw)) as {
ticket?: string;
methods?: MfaMethod[];
};
if (parsed.ticket) {
// A lazy useState initializer cannot replace this: `document` does
// not exist during the server render, and seeding client-only state
// there would desync hydration. Mount-time pickup of an external
// one-shot store is exactly the effect escape hatch.
// eslint-disable-next-line react-hooks/set-state-in-effect
setMfaChallenge({
ticket: parsed.ticket,
methods: parsed.methods ?? ["totp", "recovery"],
});
}
} catch {
// Malformed handoff — fall through to the normal login UI.
}
}, []);
// v1.23 — passkey conditional-UI autofill. When the browser supports it,
// arm a discoverable-credential assertion bound to the username field so a
// returning user can pick their passkey straight from the autofill prompt.
// Best-effort: any abort (the user types a password instead) is swallowed.
// Waits for the OIDC status and stays off entirely under OIDC_ONLY —
// passkey login is a dead end there (server-enforced), so arming the
// browser's credential prompt would only dangle a door that is locked.
useEffect(() => {
if (oidcStatus === undefined) return;
if (oidcStatus.only === true) return;
if (autofillStarted.current) return;
autofillStarted.current = true;
let cancelled = false;
@@ -231,7 +301,7 @@ export default function LoginPage() {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [oidcStatus]);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
@@ -268,102 +338,128 @@ export default function LoginPage() {
</div>
) : (
<div className="mt-8 space-y-4">
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
to size="lg" so the login CTAs meet WCAG 2.5.5 (44px
minimum) on mobile. Login is the most-tapped flow on a
fresh install. */}
<Button
onClick={handlePasskeyLogin}
className="min-h-11 w-full"
size="lg"
disabled={loading}
>
{loading && mode === "passkey" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("auth.loginWithPasskey")}
</Button>
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">
{t("common.or")}
</span>
<Separator className="flex-1" />
</div>
{mode === "passkey" ? (
{oidcStatus?.enabled && (
<Button
variant="outline"
onClick={handleOidcLogin}
variant={oidcStatus.only ? "default" : "outline"}
className="min-h-11 w-full"
size="lg"
onClick={() => setMode("password")}
>
<Lock className="h-4 w-4" />
{t("auth.loginWithPassword")}
<Shield className="h-4 w-4" />
{oidcStatus.buttonLabel || t("auth.oidc.signInDefault")}
</Button>
) : (
<form onSubmit={handlePasswordLogin} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
<Input
id="email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
// `webauthn` token arms the conditional-UI passkey
// autofill prompt on this field (see the mount effect).
autoComplete="username webauthn"
inputMode="email"
enterKeyHint="next"
autoCapitalize="none"
spellCheck={false}
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder={t("auth.emailOrUsernamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
enterKeyHint="go"
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder="********"
/>
</div>
)}
{oidcStatus?.enabled && !oidcStatus.only && (
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">
{t("common.or")}
</span>
<Separator className="flex-1" />
</div>
)}
{!oidcStatus?.only && (
<>
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
to size="lg" so the login CTAs meet WCAG 2.5.5 (44px
minimum) on mobile. Login is the most-tapped flow on a
fresh install. */}
<Button
type="submit"
onClick={handlePasskeyLogin}
className="min-h-11 w-full"
size="lg"
disabled={loading}
>
{loading && mode === "password" ? (
{loading && mode === "passkey" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="h-4 w-4" />
<KeyRound className="h-4 w-4" />
)}
{t("auth.login")}
{t("auth.loginWithPasskey")}
</Button>
<button
type="button"
onClick={() => setMode("passkey")}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{t("auth.backToPasskey")}
</button>
</form>
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">
{t("common.or")}
</span>
<Separator className="flex-1" />
</div>
{mode === "passkey" ? (
<Button
variant="outline"
className="min-h-11 w-full"
size="lg"
onClick={() => setMode("password")}
>
<Lock className="h-4 w-4" />
{t("auth.loginWithPassword")}
</Button>
) : (
<form onSubmit={handlePasswordLogin} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
<Input
id="email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
// `webauthn` token arms the conditional-UI passkey
// autofill prompt on this field (see the mount effect).
autoComplete="username webauthn"
inputMode="email"
enterKeyHint="next"
autoCapitalize="none"
spellCheck={false}
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder={t("auth.emailOrUsernamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
enterKeyHint="go"
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder="********"
/>
</div>
<Button
type="submit"
className="min-h-11 w-full"
size="lg"
disabled={loading}
>
{loading && mode === "password" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="h-4 w-4" />
)}
{t("auth.login")}
</Button>
<button
type="button"
onClick={() => setMode("passkey")}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{t("auth.backToPasskey")}
</button>
</form>
)}
</>
)}
{error && (
@@ -377,7 +473,7 @@ export default function LoginPage() {
</div>
)}
{registrationEnabled === true && (
{!oidcStatus?.only && registrationEnabled === true && (
<p className="text-muted-foreground text-center text-xs">
{t("auth.noAccount")}{" "}
<Link
@@ -36,6 +36,11 @@ vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
vi.mock("@/lib/clinician-share/share-view-data", () => ({
loadShareViewData: vi.fn(),
}));
// Issue #490 — the page resolves the share OWNER's timezone for the date
// rendering; the Prisma-backed resolver is stubbed like the data loader.
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/components/clinician/clinician-view", () => ({
ClinicianView: () => null,
}));
+11 -2
View File
@@ -31,6 +31,7 @@ import {
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { ClinicianView } from "@/components/clinician/clinician-view";
import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate";
@@ -103,8 +104,15 @@ export default async function ClinicianSharePage({
const context = await resolveShareToken(token);
if (!context) notFound();
const [{ report, sections, documents, documentOnly }, locale] =
await Promise.all([loadShareViewData(context), resolveLocale()]);
// Issue #490 — period/expiry dates render in the share OWNER's (patient's)
// profile timezone so they agree with the patient-tz aggregation behind the
// stats and with the doctor-report PDF (never the container's zone).
const [{ report, sections, documents, documentOnly }, locale, ownerTimezone] =
await Promise.all([
loadShareViewData(context),
resolveLocale(),
resolveUserTimezone(context.ownerUserId),
]);
const { t } = getServerTranslator(locale);
return (
@@ -118,6 +126,7 @@ export default async function ClinicianSharePage({
documentOnly={documentOnly}
token={token}
locale={locale}
timezone={ownerTimezone}
/>
);
}
+2 -6
View File
@@ -41,7 +41,7 @@ interface ComprehensiveMoodData {
}
export default function InsightsStimmungPage() {
const { isAuthenticated, user } = useAuth();
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
@@ -107,11 +107,7 @@ export default function InsightsStimmungPage() {
MeasurementType series, so the period-over-period range read
(`/api/analytics/range`, keyed on a MeasurementType enum) has
nothing to aggregate. */}
<MoodChart
chartKey="mood"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<MoodChart chartKey="mood" compareBaseline={compareBaseline} />
<MetricTargetSummary slug="mood" />
-1
View File
@@ -1375,7 +1375,6 @@ export default function DashboardPageClient() {
onDataReady={() => markChartReady("mood-chart")}
compareBaseline={compareBaseline}
chartKey="mood"
userTimezone={user?.timezone}
/>
),
});
+10 -9
View File
@@ -35,7 +35,7 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
import { Button } from "@/components/ui/button";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import {
bucketTimeSeries,
pickBucket,
@@ -595,14 +595,15 @@ export function HealthChart({
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
const fmt = useFormatters();
// v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip
// date strings. `useFormatters()` reads the active UI locale only;
// this builds a locale + userTz pair so a Pacific/Auckland user reads
// their own day on the axis.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
// chart formats encodes a CALENDAR DAY (noon-UTC of a profile-tz day
// key, or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start),
// so labels render UTC-pinned via `makeBucketLabelFormatters` — the
// encoded day paints verbatim in every profile zone. Rendering these
// encodings through a profile-tz formatter shifted week/month bucket
// labels a day/month back for zones west of Berlin. Day-key BUCKETING
// below still uses `userTimezone` — only labels are pinned.
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
// v1.4.25 W7b — per-row day-key formatter used to bucket measurement
// rows by the user's local calendar day. Memoised on userTimezone so
// the inner per-row loop reuses a single Intl.DateTimeFormat.
+8 -14
View File
@@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import type { DataSummary } from "@/lib/analytics/trends";
import {
bucketTimeSeries,
@@ -90,12 +90,6 @@ interface MoodChartProps {
* only surface — the cog stays hidden and overlays are clean.
*/
chartKey?: ChartOverlayKey;
/**
* v1.4.25 W7b — per-user display timezone. When passed, the x-axis
* tick labels and tooltip date render in the user's tz. Defaults to
* "Europe/Berlin" so older mount sites stay byte-identical.
*/
userTimezone?: string;
/**
* v1.16.0 — fires once the mood analytics query has settled (initial
* load finished). The dashboard's shared reveal gate listens here so
@@ -304,17 +298,17 @@ export function MoodChart({
windowOverride,
compareBaseline = "none",
chartKey,
userTimezone = "Europe/Berlin",
onDataReady,
}: MoodChartProps) {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
// v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip
// date strings.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
// chart formats encodes a CALENDAR DAY (noon-UTC of a server day key,
// or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start), so
// labels render UTC-pinned via `makeBucketLabelFormatters` — the
// encoded day paints verbatim in every profile zone (a profile-tz
// formatter shifted week/month bucket labels for zones west of Berlin).
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
const initialRangePoints = windowOverride
? MINI_RANGE_POINTS[windowOverride]
: 30;
+18 -2
View File
@@ -17,6 +17,8 @@ import { formatBytes } from "@/components/documents/vault-utils";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { makeFormatters } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
@@ -53,7 +55,17 @@ interface ClinicianViewProps {
/** The raw share token from the path — used to build serve-route URLs. */
token?: string;
/** Viewer locale (byte formatting only). Defaults to English. */
locale?: string;
locale?: Locale;
/**
* Issue #490 — the share OWNER's (patient's) profile timezone. Period
* start/end and the expiry date render in this zone so the dates agree
* with the patient-tz aggregation behind the stats (and with the
* doctor-report PDF). Pre-fix the dates rendered via a bare
* `toLocaleDateString()` — the server CONTAINER's zone (≈ UTC), neither
* the patient's nor the viewer's, with US-English field order regardless
* of the resolved page locale.
*/
timezone?: string;
}
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
@@ -197,8 +209,12 @@ export function ClinicianView({
documentOnly = false,
token = "",
locale = "en",
timezone,
}: ClinicianViewProps) {
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString();
// Owner-tz, locale-aware date rendering (issue #490) — `makeFormatters`
// guards the zone and falls back to Europe/Berlin on garbage/absence.
const fmt = makeFormatters(locale, timezone);
const fmtDate = (iso: string) => fmt.date(new Date(iso));
const fmtNum = (n: number) => Math.round(n * 100) / 100;
// A documents-only share carries no report — the aggregator is never called
@@ -3,9 +3,10 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* The floating bulk bar, pinned via single-pass static renders: a labelled
* toolbar carrying the selected count and the four bulk verbs. The
* link-condition menu only renders when the account actually has episodes
* to link — no dead affordance on an episode-free account.
* toolbar carrying the selected count and the bulk verbs (set type, link
* condition, share, delete, clear). The link-condition menu only renders when
* the account actually has episodes to link — no dead affordance on an
* episode-free account.
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentBulkBar } from "../document-bulk-bar";
@@ -19,6 +20,7 @@ function render(episodes: { id: string; label: string }[]) {
busy={false}
onSetKind={() => {}}
onLinkEpisode={() => {}}
onShare={() => {}}
onDelete={() => {}}
onClear={() => {}}
/>
@@ -34,6 +36,8 @@ describe("<DocumentBulkBar>", () => {
expect(html).toContain("3 selected");
expect(html).toContain("Change type");
expect(html).toContain("Link condition");
expect(html).toContain('data-slot="document-bulk-share"');
expect(html).toContain("Share");
expect(html).toContain("Delete");
expect(html).toContain("Clear selection");
});
@@ -115,6 +115,53 @@ describe("<DocumentCard>", () => {
expect(html).not.toContain("/thumbnail");
});
it("does NOT render the AI-read glyph on the card face, even when AI-read", () => {
// The AI-read state stays surfaced authoritatively in the detail sheet's
// DocumentAiSection; the card face no longer duplicates it (v1.28.38).
const html = render(
<DocumentCard
document={doc({ hasContentIndex: true, contentIndexSource: "vision" })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).not.toContain('data-slot="document-card-ai-read"');
});
it("renders the filename on its own line with the full name in title", () => {
const html = render(
<DocumentCard
document={doc({
title: "MRT Knie",
filename: "mrt-knie-2025-10-04.pdf",
})}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
// Dedicated filename line, muted text-xs, full name reachable on hover.
expect(html).toContain('data-slot="document-card-filename"');
expect(html).toContain('title="mrt-knie-2025-10-04.pdf"');
expect(html).toContain("mrt-knie-2025-10-04.pdf");
});
it("omits the filename line when the filename equals the title", () => {
const html = render(
<DocumentCard
document={doc({ title: "report.pdf", filename: "report.pdf" })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).not.toContain('data-slot="document-card-filename"');
});
it("falls back to the untitled label and rings when highlighted", () => {
const html = render(
<DocumentCard
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The document-launched share sheet now accepts an ARRAY of documents so the
* bulk multi-select can fold the whole selection into ONE documents-only link.
* The single-doc (detail sheet) caller passes a one-element array.
*
* ResponsiveSheet wraps its body in a Radix portal that renderToStaticMarkup
* won't materialise, so both it and the create form are mocked down to plain
* wrappers that surface the title + the seeded form props.
*/
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
title,
children,
}: {
title: React.ReactNode;
children: React.ReactNode;
[key: string]: unknown;
}) => (
<div data-slot="mock-responsive-sheet">
<div data-slot="sheet-title">{title}</div>
{children}
</div>
),
}));
vi.mock("@/components/settings/share-link-create-form", () => ({
ShareLinkCreateForm: (props: {
documentOnly?: boolean;
initialDocuments?: { id: string; title: string }[];
initialLabel?: string;
}) => (
<div
data-slot="mock-share-form"
data-document-only={String(props.documentOnly ?? false)}
data-initial-label={props.initialLabel ?? ""}
data-doc-ids={(props.initialDocuments ?? []).map((d) => d.id).join(",")}
data-doc-count={String((props.initialDocuments ?? []).length)}
/>
),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentShareSheet } from "../document-share-sheet";
function render(documents: { id: string; title: string }[]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentShareSheet open onOpenChange={() => {}} documents={documents} />
</I18nProvider>,
);
}
describe("<DocumentShareSheet> — one link for the selection", () => {
it("seeds a single-doc share with the document title as the label", () => {
const html = render([{ id: "d1", title: "Blood panel" }]);
expect(html).toContain("Share this document");
expect(html).toContain('data-document-only="true"');
expect(html).toContain('data-doc-ids="d1"');
expect(html).toContain('data-initial-label="Blood panel"');
});
it("folds N selected docs into ONE documents-only link with a count label", () => {
const html = render([
{ id: "d1", title: "A" },
{ id: "d2", title: "B" },
{ id: "d3", title: "C" },
]);
// Multi title, a "{count} documents" summary label, all ids in one link.
expect(html).toContain("Share these documents");
expect(html).toContain('data-doc-ids="d1,d2,d3"');
expect(html).toContain('data-doc-count="3"');
expect(html).toContain('data-initial-label="3 documents"');
// Privacy: documents-only stays frozen on for the launched share.
expect(html).toContain('data-document-only="true"');
});
it("does not mount the form when closed", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentShareSheet
open={false}
onOpenChange={() => {}}
documents={[{ id: "d1", title: "A" }]}
/>
</I18nProvider>,
);
expect(html).not.toContain('data-slot="mock-share-form"');
});
});
@@ -12,6 +12,8 @@ import {
formatMonthLabel,
parseUploadResponse,
parseVaultSearchParams,
resolveBulkShareDocuments,
SHARE_LINK_MAX_DOCUMENTS,
vaultFiltersToSearch,
} from "../vault-utils";
@@ -284,3 +286,60 @@ describe("shift-click range selection", () => {
]).toEqual([]);
});
});
describe("resolveBulkShareDocuments", () => {
const corpus = [
doc({ id: "d1", title: "Blood panel", filename: "bp.pdf" }),
doc({ id: "d2", title: null, filename: "scan.jpg" }),
doc({ id: "d3", title: null, filename: null }),
doc({ id: "d4", title: "Referral" }),
];
it("maps the selection to {id,title}, falling back title → filename → untitled", () => {
const result = resolveBulkShareDocuments(
corpus,
new Set(["d1", "d2", "d3"]),
"Untitled",
);
expect(result.overCap).toBe(false);
if (result.overCap) throw new Error("unexpected over-cap");
expect(result.documents).toEqual([
{ id: "d1", title: "Blood panel" },
{ id: "d2", title: "scan.jpg" },
{ id: "d3", title: "Untitled" },
]);
});
it("preserves corpus order and ignores ids not in the corpus", () => {
const result = resolveBulkShareDocuments(
corpus,
new Set(["d4", "d1", "ghost"]),
"Untitled",
);
if (result.overCap) throw new Error("unexpected over-cap");
expect(result.documents.map((d) => d.id)).toEqual(["d1", "d4"]);
});
it("caps at SHARE_LINK_MAX_DOCUMENTS (50) — over the cap refuses rather than truncates", () => {
const many = Array.from({ length: 60 }, (_, i) => doc({ id: `x${i}` }));
const selected = new Set(many.map((d) => d.id));
expect(selected.size).toBeGreaterThan(SHARE_LINK_MAX_DOCUMENTS);
expect(resolveBulkShareDocuments(many, selected, "Untitled")).toEqual({
overCap: true,
});
});
it("allows exactly 50 selected documents", () => {
const fifty = Array.from({ length: SHARE_LINK_MAX_DOCUMENTS }, (_, i) =>
doc({ id: `y${i}` }),
);
const result = resolveBulkShareDocuments(
fifty,
new Set(fifty.map((d) => d.id)),
"Untitled",
);
expect(result.overCap).toBe(false);
if (result.overCap) throw new Error("unexpected over-cap");
expect(result.documents).toHaveLength(SHARE_LINK_MAX_DOCUMENTS);
});
});
+25 -6
View File
@@ -4,15 +4,16 @@
* Floating bulk-action bar for the vault's multi-select mode. Appears once
* at least one document is selected, pinned above the bottom nav on phones
* and near the bottom edge on desktop. Carries the selected count and the
* four bulk verbs — set type, link condition, delete (undo-able), clear —
* all driving `POST /api/documents/inbound/bulk` from the page.
* bulk verbs — set type, link condition, share (one link for the whole
* selection), delete (undo-able), clear — all driving the page's handlers.
*
* A `role="toolbar"` with proper labels; the destructive verb sits last
* before Clear so a stray tap sequence never ends on Delete. The bar is a
* deliberate hand-rolled shell (a floating toolbar is not a Card surface):
* dense-tile padding `p-3` per the standards.
* before Clear so a stray tap sequence never ends on Delete. Share sits just
* before Delete (the safe actions lead). The bar is a deliberate hand-rolled
* shell (a floating toolbar is not a Card surface): dense-tile padding `p-3`
* per the standards.
*/
import { FolderPlus, Tag, Trash2, X } from "lucide-react";
import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -37,6 +38,7 @@ export function DocumentBulkBar({
busy,
onSetKind,
onLinkEpisode,
onShare,
onDelete,
onClear,
}: {
@@ -47,6 +49,12 @@ export function DocumentBulkBar({
busy: boolean;
onSetKind: (kind: InboundDocumentKindValue) => void;
onLinkEpisode: (episodeId: string) => void;
/**
* Share the whole selection as ONE documents-only link. The page caps the
* selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint —
* this handler just opens the share sheet seeded with the selection.
*/
onShare: () => void;
onDelete: () => void;
onClear: () => void;
}) {
@@ -108,6 +116,17 @@ export function DocumentBulkBar({
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={onShare}
data-slot="document-bulk-share"
>
<Share2 className="size-4" aria-hidden />
{t("documents.bulk.share")}
</Button>
{/* Solid destructive (matching the detail sheet's Delete) — the
outline variant's destructive text on the card surface fails the
WCAG contrast gate. */}
+17 -23
View File
@@ -9,13 +9,14 @@
* Anatomy per the design standards: a leading edge that shows a small preview
* thumbnail tile when one has been rendered (`hasThumbnail`) and otherwise the
* kind icon `text-foreground size-5`, title in foreground (`text-sm
* font-medium`, truncated), one muted meta line (date · size · filename),
* font-medium`, truncated), a muted meta line (date · size) with the real
* filename on its own muted line below (full name on hover via `title`),
* condition-tag pills, an attachment-class badge for download-only formats,
* and a selection checkbox that appears on hover / focus / while selected. The
* whole card is clickable through an invisible overlay button; the checkbox
* floats above it.
*/
import { Download, Sparkles, X } from "lucide-react";
import { Download, X } from "lucide-react";
import { useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
@@ -24,10 +25,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
isAiReadSource,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import type { UploadQueueItem } from "./use-document-upload";
import { documentDateKey, formatBytes } from "./vault-utils";
@@ -87,8 +85,6 @@ export function DocumentCard({
const size = formatBytes(document.byteSize, locale);
const showFilename =
document.filename !== null && document.filename !== title;
const aiRead =
document.hasContentIndex && isAiReadSource(document.contentIndexSource);
return (
<Card
@@ -133,22 +129,20 @@ export function DocumentCard({
)}
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm font-medium">{title}</p>
<p className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs">
<span className="truncate">
{date} · {size}
{showFilename ? ` · ${document.filename}` : ""}
</span>
{aiRead ? (
// AI-read marker rides INLINE on the meta row — a bare glyph,
// no pill line (the tag ate a whole row in the dense grid).
<Sparkles
data-slot="document-card-ai-read"
role="img"
aria-label={t("documents.ai.statusAiRead")}
className="text-primary size-3 shrink-0"
/>
) : null}
<p className="text-muted-foreground truncate text-xs">
{date} · {size}
</p>
{showFilename ? (
// Filename on its OWN muted line so it never clips the date/size
// meta; the full name stays reachable on hover via `title`.
<p
data-slot="document-card-filename"
className="text-muted-foreground truncate text-xs"
title={document.filename ?? undefined}
>
{document.filename}
</p>
) : null}
</div>
<Checkbox
checked={selected}
@@ -62,6 +62,7 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
isAiReadSource,
type DocumentSuggestionDto,
type DocumentSummaryMode,
type InboundDocumentDetailDto,
@@ -420,6 +421,12 @@ export function DocumentDetailSheet({
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
// AI-read provenance — surfaced here (the detail view) as calm muted meta
// since v1.28.38 dropped the vault-card glyph. Same condition the AI section
// uses: a content index exists AND its source is a provider vision/text read.
const aiRead = doc
? doc.hasContentIndex && isAiReadSource(doc.contentIndexSource)
: false;
return (
<>
@@ -752,6 +759,15 @@ export function DocumentDetailSheet({
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
{aiRead ? (
<p
data-slot="document-detail-ai-read"
className="text-muted-foreground text-xs"
>
{t("documents.ai.statusAiRead")}
</p>
) : null}
</div>
</div>
) : null}
@@ -761,8 +777,7 @@ export function DocumentDetailSheet({
<DocumentShareSheet
open={shareOpen}
onOpenChange={setShareOpen}
documentId={doc.id}
documentTitle={title}
documents={[{ id: doc.id, title }]}
/>
) : null}
@@ -3,46 +3,62 @@
/**
* The document-launched entry point into the clinician share-link create flow.
* Mounts the shared `ShareLinkCreateForm` inside a `ResponsiveSheet` (bottom
* sheet on phones, dialog on desktop) with the current document pre-attached
* and its title pre-filled as the link label — the owner shares the document
* they were looking at without leaving for Settings, and the one-time QR /
* passphrase reveal lands in the same surface.
* sheet on phones, dialog on desktop) with the picked document(s) pre-attached
* and a label pre-filled — the owner shares the document(s) they were looking
* at (detail sheet) or selected (bulk multi-select) without leaving for
* Settings, and the one-time QR / passphrase reveal lands in the same surface.
*
* Accepts an ARRAY: a single-document share (detail sheet) passes one entry; a
* bulk share passes the whole selection, all folded into ONE `documentOnly`
* link (the share model carries up to `SHARE_LINK_MAX_DOCUMENTS` docs per
* link). The label defaults to the single title, or a `{count} documents`
* summary for a multi-doc share.
*
* The frozen-write-once + ≤50 rules, the EXIF note, and expiry all come from
* the shared form as-is; the same `POST /api/share-links` handles the create.
* The form is mounted only while the sheet is open so each open starts a fresh
* link (the one-time secret is never re-shown for a stale create).
* the shared form as-is; the same `POST /api/share-links` handles the create
* and re-verifies every id as the owner's own live document. The form is
* mounted only while the sheet is open so each open starts a fresh link (the
* one-time secret is never re-shown for a stale create).
*/
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import type { PickedDocument } from "@/components/settings/share-document-picker";
import { ShareLinkCreateForm } from "@/components/settings/share-link-create-form";
import { useTranslations } from "@/lib/i18n/context";
export function DocumentShareSheet({
open,
onOpenChange,
documentId,
documentTitle,
documents,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
documentId: string;
documentTitle: string;
/** The document(s) to pre-attach — one entry (detail) or many (bulk). */
documents: PickedDocument[];
}) {
const { t } = useTranslations();
const multiple = documents.length > 1;
// Single doc → its title; multiple → a "{count} documents" summary. The
// owner can still edit the label in the form before minting the link.
const label = multiple
? t("documents.share.multiLabel", { count: documents.length })
: (documents[0]?.title ?? "");
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("documents.share.title")}
title={
multiple ? t("documents.share.multiTitle") : t("documents.share.title")
}
description={t("documents.share.description")}
contentWidth="2xl"
>
{open ? (
<ShareLinkCreateForm
documentOnly
initialDocuments={[{ id: documentId, title: documentTitle }]}
initialLabel={documentTitle}
initialDocuments={documents}
initialLabel={label}
/>
) : null}
</ResponsiveSheet>
@@ -53,6 +53,7 @@ import {
} from "@/lib/validations/inbound-documents";
import { DocumentBulkBar } from "./document-bulk-bar";
import { DocumentDetailSheet } from "./document-detail-sheet";
import { DocumentShareSheet } from "./document-share-sheet";
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
import { DocumentTimeline } from "./document-timeline";
import { UploadZone } from "./upload-zone";
@@ -65,6 +66,8 @@ import {
documentDateKey,
expandRangeSelection,
parseVaultSearchParams,
resolveBulkShareDocuments,
SHARE_LINK_MAX_DOCUMENTS,
vaultFiltersToSearch,
} from "./vault-utils";
@@ -481,6 +484,32 @@ export function DocumentsView() {
[bulk, clearSelection, restoreBulk, t],
);
// ── Bulk share ────────────────────────────────────────────────────────
// ONE documents-only link for the whole selection (the share model carries
// up to SHARE_MAX_DOCUMENTS docs per link). The titles come from the loaded
// corpus — a selected id is always a rendered row. Over the cap we surface a
// hint and refuse rather than silently dropping docs from the link.
const [bulkShareOpen, setBulkShareOpen] = useState(false);
const [bulkShareDocs, setBulkShareDocs] = useState<
{ id: string; title: string }[]
>([]);
const openBulkShare = useCallback(() => {
const resolved = resolveBulkShareDocuments(
documents,
selectedIds,
t("documents.card.untitled"),
);
if (resolved.overCap) {
toast.error(
t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }),
);
return;
}
if (resolved.documents.length === 0) return;
setBulkShareDocs(resolved.documents);
setBulkShareOpen(true);
}, [documents, selectedIds, t]);
// ── Detail sheet ──────────────────────────────────────────────────────
const [detailId, setDetailId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
@@ -662,11 +691,18 @@ export function DocumentsView() {
busy={bulk.isPending}
onSetKind={(kind) => runBulk("setKind", { kind })}
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
onShare={openBulkShare}
onDelete={() => deleteBulk([...selectedIds])}
onClear={clearSelection}
/>
) : null}
<DocumentShareSheet
open={bulkShareOpen}
onOpenChange={setBulkShareOpen}
documents={bulkShareDocs}
/>
{/* Page-wide drop overlay — pure decoration (aria-hidden); the intake
itself announces through the live region below. */}
{dropActive ? (
+36
View File
@@ -223,6 +223,42 @@ export function expandRangeSelection(
return next;
}
// ─── Bulk share selection ──────────────────────────────────────────────────
/**
* Client mirror of the server's `SHARE_LINK_MAX_DOCUMENTS` — one share link
* carries at most 50 documents. Kept as a local literal (NOT imported from the
* clinician-share validations module, which pulls the Prisma client into scope
* and would drag the DB into the client bundle); the server re-enforces the cap
* on create regardless. The bulk-SELECT cap (`DOCUMENT_BULK_MAX_IDS`) is higher
* (100), so a large selection is capped for the share path with an explicit hint.
*/
export const SHARE_LINK_MAX_DOCUMENTS = 50;
/**
* Map a selection over the loaded corpus onto the `{ id, title }` list a share
* link seeds from. Returns `{ overCap: true }` when the selection exceeds
* `SHARE_LINK_MAX_DOCUMENTS` — the caller surfaces the hint and refuses rather
* than silently dropping documents from the link. `untitledLabel` is injected
* so the helper stays pure / i18n-free.
*/
export function resolveBulkShareDocuments(
documents: readonly InboundDocumentDto[],
selected: ReadonlySet<string>,
untitledLabel: string,
):
| { overCap: true }
| { overCap: false; documents: { id: string; title: string }[] } {
if (selected.size > SHARE_LINK_MAX_DOCUMENTS) return { overCap: true };
const picked = documents
.filter((d) => selected.has(d.id))
.map((d) => ({
id: d.id,
title: d.title ?? d.filename ?? untitledLabel,
}));
return { overCap: false, documents: picked };
}
// ─── Upload response contract (§3.2) ───────────────────────────────────────
/**
@@ -0,0 +1,76 @@
/**
* v1.28.30 — wire-contract for the explicit regenerate.
*
* House rule: generation = nightly cron + explicit button. The button's
* mutation therefore MUST issue `POST { force: true }` — without `force`
* the route's 24 h short-circuit re-serves the cached payload and the
* button reads as doing nothing (one half of the recurring
* "no briefing today" chain). The read path stays a plain GET.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const apiFetchRaw = vi.fn();
vi.mock("@/lib/api/api-fetch", () => ({
apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a),
}));
import { fetchAdvisor } from "../use-insights-advisor";
function jsonResponse(data: unknown): Response {
return {
ok: true,
status: 200,
json: async () => ({ data }),
} as unknown as Response;
}
beforeEach(() => {
vi.clearAllMocks();
apiFetchRaw.mockResolvedValue(
jsonResponse({ insights: { dailyBriefing: null }, cached: false }),
);
});
describe("fetchAdvisor — explicit regenerate forces a generation", () => {
it("POSTs force: true on the regenerate path", async () => {
const result = await fetchAdvisor({ force: true });
expect(apiFetchRaw).toHaveBeenCalledTimes(1);
const [url, init] = apiFetchRaw.mock.calls[0] as [
string,
{ method: string; body?: string },
];
expect(url).toBe("/api/insights/generate");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body ?? "{}")).toEqual({ force: true });
expect(result.outcome).toBe("fresh");
});
it("uses a read-only GET (no body, no force) on the read path", async () => {
await fetchAdvisor();
expect(apiFetchRaw).toHaveBeenCalledTimes(1);
const [url, init] = apiFetchRaw.mock.calls[0] as [
string,
{ method: string; body?: string },
];
expect(url).toBe("/api/insights/generate");
expect(init.method).toBe("GET");
expect(init.body).toBeUndefined();
});
it("lifts briefingOmittedReason through the payload for the card", async () => {
apiFetchRaw.mockResolvedValue(
jsonResponse({
insights: { dailyBriefing: null },
cached: true,
briefingOmittedReason: "ungrounded",
}),
);
const result = await fetchAdvisor();
expect(result.payload?.briefingOmittedReason).toBe("ungrounded");
expect(result.payload?.dailyBriefing).toBeNull();
});
});
+9 -4
View File
@@ -25,10 +25,13 @@ import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import type {
DailyBriefing as DailyBriefingPayload,
@@ -371,7 +374,9 @@ export function DailyBriefing({
}: DailyBriefingProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
// Issue #490 — the SAME resolved zone `fmt.time` renders in (mirror →
// Berlin), so the today/yesterday boundary and the clock can never split.
const displayTz = useDisplayTimezone();
// v1.25.3 — when there is no last good text and the last attempt failed,
// point the hint at the lever that matches the failure class. A timeout (the
@@ -493,7 +498,7 @@ export function DailyBriefing({
t,
fmt.dateShort,
fmt.time,
user?.timezone,
displayTz,
)}
</p>
)}
+9 -2
View File
@@ -3,7 +3,11 @@
import Link from "next/link";
import { Sparkles } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { useAuth } from "@/hooks/use-auth";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
@@ -188,6 +192,9 @@ export function HeroStrip({
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
// Issue #490 — the SAME resolved zone `fmt.time` renders in (mirror →
// Berlin), so the today/yesterday boundary and the clock can never split.
const displayTz = useDisplayTimezone();
// v1.21.2 (A5) — localise the Tension Verdict's contributor keys into the
// card's already-localised `{ positive, negative }` shape. Only forwarded when
@@ -237,7 +244,7 @@ export function HeroStrip({
// hero no longer reads relative while the cards below it read absolute. The
// day boundary follows the user's profile timezone.
const generatedLine = updatedAt
? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, user?.timezone)
? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz)
: null;
return (
@@ -5,12 +5,15 @@ import Link from "next/link";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
@@ -289,7 +292,9 @@ function StatusBody({ text }: { text: string }) {
function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
// Issue #490 — boundary zone == the zone `fmt.time` renders in (mirror →
// Berlin), so the today/yesterday bucket and the clock can never split.
const displayTz = useDisplayTimezone();
if (!updatedAt) return null;
return (
// v1.11.5 — right-aligned so the timestamp tucks to the trailing edge
@@ -300,13 +305,7 @@ function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
// matching the briefing + per-metric cards. The day boundary follows the
// user's profile timezone, not the browser's.
<p className="text-muted-foreground text-right text-xs">
{formatUpdatedLabel(
updatedAt,
t,
fmt.dateShort,
fmt.time,
user?.timezone,
)}
{formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz)}
</p>
);
}
@@ -4,10 +4,13 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CalendarRange } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { queryKeys } from "@/lib/query-keys";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
@@ -77,7 +80,9 @@ export function PeriodNarrativeCard({
}: PeriodNarrativeCardProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
// Issue #490 — the SAME resolved zone `fmt.time` renders in (mirror →
// Berlin), so the today/yesterday boundary and the clock can never split.
const displayTz = useDisplayTimezone();
const [period, setPeriod] = useState<NarrativePeriod>("week");
const query = useQuery({
@@ -197,7 +202,7 @@ export function PeriodNarrativeCard({
t,
fmt.dateShort,
fmt.time,
user?.timezone,
displayTz,
)}
</p>
</div>
@@ -261,11 +261,7 @@ function RationaleCard({
compliance get dedicated wrappers; everything else routes
through HealthChart with the metric-key vocabulary. */}
{isMoodMetric(metricType) ? (
<MoodChart
mini
windowOverride={rationale.dataWindow}
userTimezone={user?.timezone}
/>
<MoodChart mini windowOverride={rationale.dataWindow} />
) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? (
<HealthChart
types={chartTypes}
+1 -1
View File
@@ -112,7 +112,7 @@ function MetricChart({
const { user } = useAuth();
const userTimezone = user?.timezone;
if (config.kind === "mood") {
return <MoodChart title={title} mini userTimezone={userTimezone} />;
return <MoodChart title={title} mini />;
}
return (
<HealthChartDynamicMini
@@ -92,8 +92,12 @@ export interface InsightAdvisorPayload {
* v1.28.28 (#470) — set by the POST when the number-grounding gate stripped
* the freshly generated briefing (after its one corrective retry). Without
* it the 200 carried a silently-null briefing and the card's "no briefing
* yet" made the regenerate button read as doing nothing. Only ever present
* on a force-regenerate response; absent on the read path.
* yet" made the regenerate button read as doing nothing.
*
* v1.28.30 — also carried by the read-only GET, backed by a dated
* server-side marker: a briefing the nightly warm stripped now renders
* the same honest "withheld" state on every read until the next
* successful generation, not only on the regenerate response.
*/
briefingOmittedReason?: "ungrounded" | null;
}
@@ -169,7 +173,14 @@ interface AdvisorFetchResult {
outcome: AdvisorFetchOutcome;
}
async function fetchAdvisor(
/**
* Exported as a test seam (like `nextAdvisorPollInterval`): the explicit
* regenerate MUST post `force: true` — generation happens on the nightly
* cron and on explicit intent only, and a force-less button silently
* re-reads the 24 h cache (the "button does nothing" half of the
* no-briefing-today chain). The wire-contract test pins it.
*/
export async function fetchAdvisor(
options: { force?: boolean } = {},
): Promise<AdvisorFetchResult> {
const controller = new AbortController();
@@ -409,9 +420,10 @@ export function useInsightsAdvisorQuery(
generationFailed: query.data?.payload?.generationFailed ?? false,
// Absent → no specific lever hint; the generic failed-description holds.
generationFailureClass: query.data?.payload?.generationFailureClass ?? null,
// v1.28.28 (#470) — only a force-regenerate response carries the field
// (setQueryData writes that response into this cache); the read path's
// GET never does, so the hint clears on the next successful read.
// v1.28.28 (#470) — a force-regenerate response carries the field via
// setQueryData; since v1.28.30 the GET carries it too (marker-backed),
// so the "withheld" state persists across reads and clears on the next
// successful generation.
briefingOmittedReason: query.data?.payload?.briefingOmittedReason ?? null,
};
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { resolveKey } from "@/lib/i18n/resolve-key";
/**
* The Vorsorge reminder type labels + the document bulk-share copy resolve
* through DYNAMIC keys (`t(\`measurementReminders.types.${type}\`)`), which the
* static i18n call-site coverage guard cannot see. This test asserts the
* dynamic keys exist and resolve to a non-empty string in EVERY locale bundle —
* the guard that would have caught the missing WHO5_SCORE / SCI_SCORE labels
* (which rendered as the raw key prefix "measurementReminders.types.…").
*/
const MESSAGES_DIR = join(__dirname, "../../../../messages");
const LOCALES = readdirSync(MESSAGES_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => ({
locale: f.replace(/\.json$/, ""),
messages: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as Record<
string,
unknown
>,
}));
// The keys that MUST resolve in every locale (dynamic-key floor).
const REQUIRED_KEYS = [
// Newly added Vorsorge reminder type labels.
"measurementReminders.types.WAIST_CIRCUMFERENCE",
"measurementReminders.types.WHO5_SCORE",
"measurementReminders.types.SCI_SCORE",
// Pre-existing screening labels (regression floor).
"measurementReminders.types.PHQ9_SCORE",
"measurementReminders.types.GAD7_SCORE",
// Bulk-share copy.
"documents.bulk.share",
"documents.bulk.shareTooMany",
"documents.share.multiTitle",
"documents.share.multiLabel",
];
describe("measurement-reminder + bulk-share dynamic i18n keys", () => {
it("discovers all six shipped locales", () => {
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
"de",
"en",
"es",
"fr",
"it",
"pl",
]);
});
for (const { locale, messages } of LOCALES) {
for (const key of REQUIRED_KEYS) {
it(`resolves ${key} in ${locale}`, () => {
const value = resolveKey(messages, key);
expect(value, `${key} missing in ${locale}.json`).toBeTypeOf("string");
expect((value ?? "").trim().length).toBeGreaterThan(0);
});
}
}
});
@@ -40,7 +40,11 @@ import {
Wrench,
} from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useFormatters,
useTranslations,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { cn } from "@/lib/utils";
@@ -113,6 +117,7 @@ const TYPE_GROUPS = [
"TOTAL_BODY_WATER",
"VISCERAL_FAT",
"BODY_MASS_INDEX",
"WAIST_CIRCUMFERENCE",
],
},
// v1.27.6 — the mental-wellbeing screenings become plannable like any
@@ -804,6 +809,9 @@ function VorsorgeCard({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `fmt.date` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
const [confirmDelete, setConfirmDelete] = useState(false);
const due = relativeDueKey(reminder.nextDueAt, now);
const cadence =
@@ -1017,7 +1025,12 @@ function VorsorgeCard({
// the same `medications.today` / `medications.yesterday` keys the medication
// card renders. Falls back to "—" when there is no last-done value.
const lastValue = reminder.lastSatisfiedAt
? relativeCalendarDate(reminder.lastSatisfiedAt, t, (d) => fmt.date(d))
? relativeCalendarDate(
reminder.lastSatisfiedAt,
t,
(d) => fmt.date(d),
displayTz,
)
: "—";
// v1.18.9 (#39) — the next-due status reads as DISCREET COLOURED TEXT (never
@@ -1100,6 +1113,10 @@ function VorsorgeCard({
{reminder.endsOn && (
<p className="text-muted-foreground text-sm">
{/* `endsOn` is a real instant (`now + courseDays` at Coach
accept, plain timestamptz — NOT a `@db.Date` UTC-midnight
calendar date), so the profile-tz formatter renders the
correct local course-end day for every zone. */}
{t("measurementReminders.until", {
date: fmt.date(new Date(reminder.endsOn)),
})}
@@ -77,7 +77,12 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDateFormatPreference,
} from "@/lib/i18n/context";
import { formatDateWithWeekday } from "@/lib/date-format";
import {
invalidateKeys,
medicationDependentKeys,
@@ -152,8 +157,9 @@ export function DoseHistoryLedger({
schedules,
windowDays = 90,
}: DoseHistoryLedgerProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const fmt = useFormatters();
const dateFormatPref = useDateFormatPreference();
const { user } = useAuth();
const queryClient = useQueryClient();
const timeZone = user?.timezone || "Europe/Berlin";
@@ -437,7 +443,12 @@ export function DoseHistoryLedger({
data-day={group.dayKey}
>
<h3 className="text-muted-foreground px-1 text-xs font-medium">
{fmt.dateWithWeekday(new Date(`${group.dayKey}T00:00:00`))}
{/* Issue #490 — `dayKey` is a profile-timezone calendar day.
The old browser-local-midnight parse re-interpreted it in
the DEVICE zone and could shift the heading a day when
browser ≠ profile zone; the day-key label renders
UTC-pinned instead (zone-independent, weekday included). */}
{formatDateWithWeekday(group.dayKey, dateFormatPref, locale)}
</h3>
<ul className="border-border/60 divide-border/60 divide-y rounded-md border">
{group.rows.map((row, i) => (
@@ -9,7 +9,11 @@ import { MedicationStateBadges } from "@/components/medications/card-parts/medic
import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body";
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDisplayTimezone,
} from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
@@ -19,6 +23,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import { formatDose } from "@/lib/medications/format-dose";
import { getDayOfWeekInTz } from "@/lib/tz/local-day";
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
@@ -185,6 +190,11 @@ export function Glp1MedicationCard({
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const fmt = useFormatters();
// Issue #490 — the zone `fmt` renders dates in (mirror → Berlin). The
// weekday name paired with `fmt.dateShort` must be derived in this SAME
// zone: `Date.getDay()` reads the BROWSER zone and could name a weekday
// that contradicts the date printed next to it.
const displayTz = useDisplayTimezone();
const weekdayLabel = useWeekdayLabel();
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
// v1.8.5 — post-dose injection-site prompt (see medication-card.tsx).
@@ -403,7 +413,7 @@ export function Glp1MedicationCard({
if (!next) return null;
if (next.daysAway === 0) return t("medications.glp1NextInjectionToday");
if (next.daysAway === 1) return t("medications.glp1NextInjectionTomorrow");
const dayName = weekdayLabel(next.date.getDay());
const dayName = weekdayLabel(getDayOfWeekInTz(next.date, displayTz));
const dateShort = fmt.dateShort(next.date);
return t("medications.glp1NextInjectionDays", {
label: `${dayName}, ${dateShort}`,
@@ -453,7 +463,7 @@ export function Glp1MedicationCard({
medication.nextDueOverdue && next
? diffDays(next.date, now) === 0
? formatTime(next.date.toISOString())
: `${weekdayLabel(next.date.getDay())}, ${fmt.dateShort(next.date)}`
: `${weekdayLabel(getDayOfWeekInTz(next.date, displayTz))}, ${fmt.dateShort(next.date)}`
: null;
const nextLine =
@@ -21,7 +21,12 @@ import {
summariseCadence,
type MedicationPayload,
} from "@/components/medications/wizard/wizard-payload";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDateFormatPreference,
} from "@/lib/i18n/context";
import { formatDate as formatCalendarDate } from "@/lib/date-format";
export interface MedicationDetailSummaryProps {
name: string;
@@ -58,8 +63,9 @@ export function MedicationDetailSummary({
asNeeded = false,
startsOn,
}: MedicationDetailSummaryProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const formatters = useFormatters();
const dateFormatPref = useDateFormatPreference();
const status = resolveStatus(active, endsOn);
const statusLabel =
@@ -80,10 +86,15 @@ export function MedicationDetailSummary({
? "bg-warning"
: "bg-muted-foreground";
// Issue #490 — `Medication.startsOn` is a `@db.Date` calendar date
// (serialised as UTC midnight). The old `formatters.dateTime(startsOn)`
// re-read that instant in the display zone: it invented a meaningless
// clock time and shifted the day for zones west of UTC. A calendar date
// renders UTC-pinned (date only), correct in every zone.
const cadenceLine = oneShot
? startsOn
? t("medications.detail.cadence.oneShotOn", {
date: formatters.dateTime(startsOn),
date: formatCalendarDate(new Date(startsOn), dateFormatPref, locale),
})
: t("medications.detail.cadence.oneShotPending")
: asNeeded
@@ -215,6 +215,12 @@ export function ScheduleHistoryTimeline({
</span>
)}
</p>
{/* `validFrom` / `validUntil` (like `currentSince`
above) are real era-boundary instants
(`@db.Timestamptz`, the moment an edit archived the
plan) — NOT `@db.Date` calendar dates — so the
profile-timezone formatter renders the correct
local day (issue #490 audit). */}
<p className="text-muted-foreground text-xs tabular-nums">
{fmt.date(revision.validFrom)}
{" – "}
@@ -40,7 +40,11 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useFormatters,
useTranslations,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import {
INSTRUMENTS,
@@ -67,6 +71,9 @@ export function InstrumentCard({
}) {
const { t, locale } = useTranslations();
const { date: formatDate } = useFormatters();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
const def = INSTRUMENTS[instrument];
const key = def.i18nKey;
const title = t(`mentalHealth.instrument.${key}`);
@@ -139,7 +146,7 @@ export function InstrumentCard({
{t("mentalHealth.lastResult")}
</span>
<span className="text-foreground text-right">
{relativeCalendarDate(last.takenAt, t, formatDate)}
{relativeCalendarDate(last.takenAt, t, formatDate, displayTz)}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
@@ -11,7 +11,11 @@
* deliberate click, never pushed at the moment someone arrives to check in.
*/
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useFormatters,
useTranslations,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
@@ -31,6 +35,9 @@ export function InstrumentDetail({
}) {
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
const def = INSTRUMENTS[instrument];
const last = rows.find((r) => r.instrument === instrument);
@@ -45,7 +52,7 @@ export function InstrumentDetail({
{t("mentalHealth.lastResult")}
</span>
<span className="text-foreground text-right">
{relativeCalendarDate(last.takenAt, t, formatDate)}
{relativeCalendarDate(last.takenAt, t, formatDate, displayTz)}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
@@ -0,0 +1,108 @@
/**
* v1.28 — `<NutrientIntakeCard>` render contract.
*
* The read-only Settings → Sources list for the opt-in `nutrients`
* module: renders NOTHING while the module is off (the Modules hub is
* the consent surface), and a flat name / latest-total / days-count
* list when it is on. Test strategy mirrors `modules-section.test.tsx`:
* mock `@tanstack/react-query` + `useAuth`, render under SSR.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
interface QueryState {
data?: unknown;
isLoading: boolean;
}
let queryState: QueryState = { data: undefined, isLoading: false };
vi.mock("@tanstack/react-query", () => ({
useQuery: () => queryState,
}));
const authSpy = vi.fn<() => { user: AuthUser | null }>();
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return { ...actual, useAuth: () => authSpy() };
});
import { NutrientIntakeCard } from "../nutrient-intake-card";
function user(modules: Record<string, boolean>): AuthUser {
return { id: "user-1", modules } as unknown as AuthUser;
}
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<NutrientIntakeCard />
</I18nProvider>,
);
}
beforeEach(() => {
queryState = { data: undefined, isLoading: false };
authSpy.mockReturnValue({ user: user({ nutrients: true }) });
});
describe("<NutrientIntakeCard>", () => {
it("renders nothing while the nutrients module is off", () => {
authSpy.mockReturnValue({ user: user({ nutrients: false }) });
expect(render()).toBe("");
authSpy.mockReturnValue({ user: user({}) });
expect(render()).toBe("");
});
it("renders the synced list with name, latest total (µg rendered), and day count", () => {
queryState = {
isLoading: false,
data: {
windowDays: 14,
nutrients: [
{
nutrient: "vitamin_d",
unit: "ug",
latestDay: "2026-07-13",
latestAmount: 22.5,
daysWithData: 5,
},
{
nutrient: "caffeine",
unit: "mg",
latestDay: "2026-07-13",
latestAmount: 310,
daysWithData: 14,
},
],
},
};
const html = render();
expect(html).toContain('data-slot="nutrient-intake-card"');
expect(html).toContain("Vitamin D");
expect(html).toContain("Caffeine");
// Wire `ug` renders as µg; the day key renders verbatim.
expect(html).toContain("µg");
expect(html).toContain("2026-07-13");
const rows = html.match(/data-slot="nutrient-intake-row"/g) ?? [];
expect(rows).toHaveLength(2);
// Read-only surface: the only interactive element is the modules link.
expect(html).toContain('data-slot="nutrient-intake-modules-link"');
expect(html).not.toContain("<button");
});
it("renders the empty state when the module is on but nothing has synced", () => {
queryState = {
isLoading: false,
data: { windowDays: 14, nutrients: [] },
};
const html = render();
expect(html).toContain("No nutrient data has been synced yet.");
});
});
@@ -25,6 +25,7 @@ import { AboutMeSection } from "@/components/settings/about-me-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { storeTimezone } from "@/lib/timezone-mirror";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
@@ -142,6 +143,13 @@ export function AccountSection() {
]);
if (profileRes.ok && tzRes.ok) {
// Instant same-tab flip of every rendered timestamp (issue #490):
// mirror the accepted zone before the `/me` refetch confirms it —
// the same synchronous-update pattern the time/date-format selects
// use. `refetch()` → `fetchMe` re-writes the mirror authoritatively.
if (user && timezone && timezone !== user.timezone) {
storeTimezone(timezone);
}
setSaveMsg({ key: "settings.profileSaved" });
setSaveMsgType("success");
await refetch();
@@ -47,6 +47,7 @@ import {
FileScan,
FileText,
MessageCircleHeart,
Leaf,
Moon,
Plug,
Pill,
@@ -96,6 +97,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
inboundDocuments: FileScan,
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7).
mentalHealth: Brain,
// v1.28 — opt-in micronutrient-intake sync (Apple Health day totals).
nutrients: Leaf,
};
export function ModulesSection() {
@@ -0,0 +1,130 @@
"use client";
/**
* `<NutrientIntakeCard>` — Settings → Sources, read-only synced-nutrient
* list (v1.28).
*
* The smallest honest surface for the opt-in `nutrients` module: a flat
* list of what the server holds — per nutrient the latest synced day
* total and the days-with-data count over the last 14 days — so the
* user can verify the sync works and see exactly what the account
* stores. No route slug, no nav entry, no chart, no edit affordance;
* the module toggle in the Modules hub is the consent surface and the
* card links to it. Rendered only when the module is on (resolved
* module map from `useAuth().user.modules`, the environment-section
* pattern); reads unwrap through `apiGet`, the key rides the
* centralized factory.
*/
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Leaf } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
const WINDOW_DAYS = 14;
/** Wire `ug` renders as µg; mg / ml pass through. */
const UNIT_LABELS: Record<string, string> = { mg: "mg", ug: "µg", ml: "ml" };
interface NutrientOverviewRow {
nutrient: string;
unit: string;
latestDay: string;
latestAmount: number;
daysWithData: number;
}
interface NutrientIntakeOverview {
windowDays: number;
nutrients: NutrientOverviewRow[];
}
function formatAmount(amount: number): string {
// Day totals arrive as floats; one decimal is plenty for a glance
// list and trailing zeros read as noise.
return String(Math.round(amount * 10) / 10);
}
export function NutrientIntakeCard() {
const { t } = useTranslations();
const { user } = useAuth();
const enabled = user?.modules?.nutrients === true;
const overview = useQuery({
queryKey: queryKeys.nutrientIntake(WINDOW_DAYS),
enabled,
queryFn: () =>
apiGet<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
});
if (!enabled) return null;
const rows = overview.data?.nutrients ?? [];
return (
<SettingsCard data-slot="nutrient-intake-card">
<SettingsCardHeader
icon={Leaf}
title={t("settings.sections.sources.nutrients.title")}
description={t("settings.sections.sources.nutrients.description", {
days: WINDOW_DAYS,
})}
className="mb-3"
/>
{overview.isLoading ? (
<div className="space-y-1 pl-7" aria-hidden="true">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
) : rows.length === 0 ? (
<p className="text-muted-foreground pl-7 text-sm">
{t("settings.sections.sources.nutrients.empty")}
</p>
) : (
<ul className="divide-border divide-y rounded-md border" role="list">
{rows.map((row) => (
<li
key={row.nutrient}
data-slot="nutrient-intake-row"
className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 p-2"
>
<span className="text-sm">
{t(`nutrients.names.${row.nutrient}`)}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{t("settings.sections.sources.nutrients.latest", {
amount: formatAmount(row.latestAmount),
unit: UNIT_LABELS[row.unit] ?? row.unit,
date: row.latestDay,
})}
{" · "}
{t("settings.sections.sources.nutrients.daysWithData", {
count: row.daysWithData,
days: WINDOW_DAYS,
})}
</span>
</li>
))}
</ul>
)}
<p className="text-muted-foreground mt-3 text-xs">
{t("settings.sections.sources.nutrients.moduleHint")}{" "}
<Link
href="/settings/modules"
className="text-primary underline underline-offset-2"
data-slot="nutrient-intake-modules-link"
>
{t("settings.sections.sources.nutrients.moduleHintLink")}
</Link>
</p>
</SettingsCard>
);
}
@@ -44,6 +44,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -580,6 +581,10 @@ export function SourcesSection() {
</Link>
</p>
</SettingsCard>
{/* v1.28 — read-only synced-nutrient list for the opt-in `nutrients`
module (renders nothing while the module is off). */}
<NutrientIntakeCard />
</div>
);
}
@@ -193,6 +193,69 @@ describe("<ResponsiveSheet>", () => {
expect(html).toContain("Edit medication");
});
it("reserves the close-button gutter on the Sheet header by default", () => {
mobile = true;
const html = renderToStaticMarkup(
<ResponsiveSheet open onOpenChange={() => {}} title="Detail">
<p>body</p>
</ResponsiveSheet>,
);
// Default showCloseButton=true → the primitive paints its absolute X, so
// the header reserves the right gutter for it.
expect(html).toContain("pr-12");
mobile = false;
});
it("drops the Sheet header gutter when showCloseButton is false", () => {
mobile = true;
const html = renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Detail"
showCloseButton={false}
headerAction={<button type="button">X</button>}
>
<p>body</p>
</ResponsiveSheet>,
);
// No primitive close button → no reserved gutter; the headerAction sits
// flush to the header's p-4 right edge.
expect(html).not.toContain("pr-12");
mobile = false;
});
it("reserves the Dialog header gutter for headerAction by default", () => {
mobile = false;
const html = renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Detail"
headerAction={<button type="button">X</button>}
>
<p>body</p>
</ResponsiveSheet>,
);
expect(html).toContain("pr-9");
});
it("drops the Dialog header gutter when showCloseButton is false", () => {
mobile = false;
const html = renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Detail"
showCloseButton={false}
headerAction={<button type="button">X</button>}
>
<p>body</p>
</ResponsiveSheet>,
);
expect(html).not.toContain("pr-9");
});
it("does not throw when onOpenChange is invoked (controlled-state plumbing)", () => {
mobile = false;
const onOpenChange = vi.fn();
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more