Merge branch 'release/v1.47.4' into release/v1.47.5

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/test-as-never.json
#	src/__tests__/baselines/test-source-reads.json
#	src/__tests__/baselines/version-stamp-comments.json
#	src/app/auth/login/page.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 14:47:38 +01:00
commit 3faf1b1c8b
132 files changed
+4234 -3322

No files matched your search

+85
View File
@@ -2,6 +2,91 @@
## [Unreleased]
## [1.47.4] — 2026-11-12
### Fixed
- **Web Push comes back when you turn it on again.** After a reinstall of
the Home Screen app, cleared site data, or turning push off on your
last device, the push channel switched itself off for good. Turning
push on again stored the browser and sent a test push, but no reminder
ever followed. Subscribing now turns the channel back on and clears
earlier failures, opening the push settings repairs a lost
subscription, and migration `0497_reenable_subscribed_web_push_channels`
turns push back on for every account that has a subscribed browser but
was switched off this way. A channel that has nobody to send to (no
browser, no paired iPhone, no mail server) now pauses for an hour and
says why on the status screen instead of counting failures.
- **No more invisible push for every recorded dose.** Logging a dose sent
a silent push to every browser to close the reminder. Safari does not
allow silent pushes and could switch push off for the iPhone app
without a word. A reminder already on screen now stays until you
dismiss it or the next one replaces it, and the dose count earlier
releases left on the Home Screen icon is cleared.
- **Signing out signs this device out of push too.** The browser kept
receiving the signed-out account's medication reminders, also after
someone else signed in. Every sign-out button now removes this
browser's subscription once the server confirmed the sign-out, and a
browser only carries the reminders of the account that subscribed it
last. If signing out fails, the app stays where it is and says you are
still signed in, instead of showing the sign-in page.
- **A shared browser no longer shows the previous account's data.** The
dashboard snapshot is restored only once the signed-in account is
known and is its owner, the sign-in pages clear the previous account's
copies as soon as the server confirms there is no session, and single
sign-on and the profile take-over clear them too. The daily briefing
text is no longer written to the browser's storage.
- **Offline, the open page stays.** Without a connection, a tap on a tab
or a link ended on the bare offline page, although the banner promised
your last synced data. The page you have open now stays readable, and
a note says other pages open once you are back online; the banner and
the README say exactly that. The service worker no longer stores API
answers on the device, and answers older releases stored are deleted
on update.
- **Links arrive where they pointed.** A Home Screen shortcut, a
notification or a bookmark opened while signed out takes you to that
page after you sign in. After you sign out yourself, the next sign-in
starts from the dashboard, and Back no longer reopens the page you
left.
- **Back works as expected.** Back after signing in, registering or
taking over a profile no longer shows the form you just sent, and Back
from a detail page returns to where you were in the list.
- **Sign-in errors say what happened, in your language.** A wrong
password or code says the sign-in failed, a lockout says to wait a few
minutes, and registration says when a username or address is taken or
an invitation is no longer valid. Passkey and single-sign-on refusals
say what went wrong.
- **The iPhone push hint.** In Safari on iPhone and iPad the push
settings now tell you to add HealthLog to your Home Screen first,
instead of saying push is not supported. The test push reaches every
device you subscribed, in your language, and says why it failed.
- **A 401 or 403 from a proxy or firewall in front of HealthLog no longer
signs you out.** An account that expires while the app is open now ends
the session, so the next save does not fail with "sign in again".
- Tapping a reminder while the app already shows that page no longer
reloads the app, and setup pages seen while signed in stay out of the
offline page cache. Single sign-on starts even when the browser's
storage answers slowly.
- The clock and battery of the installed iPhone app are readable again in
the light theme.
### Changed
- **Demo instances.** Visitors can sign out, each visitor's language stays
in their own browser instead of the shared account, refused language
and timing requests no longer show up on every page, and a demo allows
30 password sign-ins per 15 minutes per address. Other instances keep
their limits.
- The "machine translated" notice no longer appears on the sign-in,
registration and profile take-over pages.
- The service worker file is half its former size.
### Migration
- `0497_reenable_subscribed_web_push_channels` turns Web Push back on for
accounts whose channel was switched off by failed sends while a browser
is still subscribed. A channel you switched off yourself stays off.
## [1.47.3] — 2026-11-12
### Changed
+1 -1
View File
@@ -56,7 +56,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install. Sign i
- **AI features (optional).** A daily briefing and a conversational coach grounded in your own measurements and read against cited guideline reference bands: where a value sits and what that means, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and skips a proactive check-in when there is nothing new to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
- **Doctor report and FHIR.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end.
- **Built to be lived in.** Installable PWA that keeps the open page readable when the connection drops, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history, in English, German, Spanish, French, Italian, Polish, Korean, Dutch, Brazilian Portuguese and Russian end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
+60 -77
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.47.3
version: 1.47.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -4016,15 +4016,16 @@ paths:
"403":
description: Refused before any work. Either the operator runs `OIDC_ONLY=true` (`meta.errorCode` = `oidc_only`), or
self-registration is closed and the request carried no invite, or the invite it carried was invalid, expired
or exhausted. The three are distinguishable by prose but not by code.
or exhausted (`auth.register.invite_invalid`).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: The email or the username is taken. Deliberately one message for both, so this is not an account-existence
oracle. Also the answer when two concurrent signups race past the pre-check and the database unique index
catches the second.
description: The email or the username is taken (`auth.register.taken`). Deliberately one message and one code for both,
so this is not an account-existence oracle. Also the answer when two concurrent signups race past the
pre-check and the database unique index catches the second. `auth.already_authenticated` when the request
carries a signed-in session.
content:
application/json:
schema:
@@ -24732,16 +24733,17 @@ paths:
post:
tags:
- Notifications
summary: Send a test push to the newest Web Push subscription
summary: Send a test push to every Web Push subscription
description: >-
Fires one notification to the caller's MOST RECENT subscription only — a person with a phone, a tablet and a
desktop registered should not get three buzzes per click. Takes no body. Rate-limited 5 per minute per user.
Fires one notification to each of the caller's subscriptions, through the same sender real reminders use, with
the text in the account's language. The device the test was pressed on gets it whichever subscription is newest.
Endpoints the push service reports gone (404/410) are removed, as on a real send. Takes no body. Rate-limited 5
per minute per user.
Read `ok`, not the status. A push that the service rejects still answers 200, with `ok: false` and the service's
own status in `perEndpoint`. Only the endpoint's HOST is reported: the full endpoint is the routing secret for
that subscription, and push services embed it in their own error messages, so it is stripped before anything is
logged or returned.
Read `ok`, not the status. A push that every service rejects still answers 200, with `ok: false`, a `reason`,
and `meta.errorCode`: `upstream_rejected`, or `connection_failed` when no push service answered. No endpoint is
returned: the full endpoint is the routing secret for that subscription.
responses:
"200":
description: The test ran. `ok` says whether the push was accepted; a false here is a real failure reported inside a
@@ -39724,42 +39726,43 @@ components:
anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending,
auth.admin.required, auth.already_authenticated, auth.mfa.already_active, auth.mfa.code_invalid,
auth.missing, auth.passkey.last_method, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked,
auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient,
auth.session.insecure_transport, auth.session.unavailable, auth.stepup.required, auth.token.expired,
auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.owner.exists,
backup.owner.missing, backup.payload.undecryptable, backup.restore.active,
backup.restore.rows_exist_elsewhere, backup.section.missing, backup.target.admin_account,
backup.target.holds_records, backup.target.managed_profile, backup.target.not_found,
backup.target.pending_deletion, backup.target.unconfirmed, backup.target.username_taken,
backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach —
coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound,
coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound,
coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable,
coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json,
coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; customMetric
— customMetric.name_taken; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
cycle.disabled, cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists,
cycle.feed.fertile_goal, cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited,
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected,
dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited,
dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound,
documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfRendererUnavailable,
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
auth.register.invite_invalid, auth.register.taken, auth.reproof.failed, auth.reproof.required,
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport,
auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing,
backup.payload.undecryptable, backup.restore.active, backup.restore.rows_exist_elsewhere,
backup.section.missing, backup.target.admin_account, backup.target.holds_records,
backup.target.managed_profile, backup.target.not_found, backup.target.pending_deletion,
backup.target.unconfirmed, backup.target.username_taken, backup.upload.too_large; biomarkers —
biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded,
coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
consent — consent.ai.confirmRequired, consent.ai.required; customMetric — customMetric.name_taken; cycle
— cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists, cycle.feed.fertile_goal,
cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited, cycle.period.invalid,
cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json,
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed;
dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid,
dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo —
demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid,
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted,
documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit,
documents.inbound.pdfRendererUnavailable, documents.inbound.providerUnsupported,
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
documents.sources.organizationRequired, documents.sources.originNotAllowed,
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
@@ -90554,40 +90557,18 @@ components:
properties:
ok:
type: boolean
sent:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: 1 on success, 0 on failure. Never more than one.
description: Whether at least one of the account's subscriptions accepted the push.
latencyMs:
description: Round-trip to the push service. Present on success only.
description: Time to send to every subscription. Present on success only.
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
perEndpoint:
type: array
items:
type: object
properties:
host:
type: string
description: Host of the push service the test went to — the endpoint path is a routing secret and is never returned.
status:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: The push service's HTTP status, or null when the attempt threw before one existed.
required:
- host
- status
additionalProperties: false
description: "One entry: only the newest subscription is tested."
reason:
description: Why it was not, when `ok` is false. Absent on success. The sender's own classification, the same code a
failed reminder records (`web_push_410_gone` when every endpoint is gone, a status-derived code otherwise).
type: string
required:
- ok
- sent
- perEndpoint
additionalProperties: false
ApnsTestEnvelope:
type: object
@@ -90899,7 +90880,9 @@ components:
- sending_paused
description: Server-derived. `auto_disabled` — the dispatcher turned it off after repeated hard rejects
(`disabledReason` says why). `manually_disabled` — the user turned it off. `sending_paused` — still enabled
but inside a retry cooldown until `nextRetryAt`. `active` — enabled and sending.
but inside a retry cooldown until `nextRetryAt`, after a transient failure or after a send that had nobody
to go to (no browser subscribed, no iPhone paired, a transport the operator has not set up;
`lastFailureReason` names it, `consecutiveFailures` is not raised). `active` — enabled and sending.
disabledReason:
description: Why the dispatcher auto-disabled the channel; null when it did not. This is the field that separates
`auto_disabled` from `manually_disabled`.
+16 -24
View File
@@ -111,17 +111,9 @@ v1.18.4:
- **Reminders.** Medication doses, mood check-ins, and any other event
the user has enabled, delivered to the installed app even when it is
closed.
- **Taken → clears the reminder.** When you log a dose, the server sends
a silent `type:"clear"` push that closes the still-pending dose-due
reminder for that slot (matched on its stable tag) without showing a
new notification (`public/sw.js`). This is the PWA equivalent of ending
an iOS Live Activity.
- **An app badge for outstanding doses.** The icon on the Home Screen
carries a server-authoritative count of doses still due; logging a dose
decrements it, and a count of zero clears the badge. The count is
computed on the server and reflected by the service worker via the
Badging API (`navigator.setAppBadge`), feature-detected — engines
without it (most desktop Firefox, older Safari) silently no-op.
- **A reminder stays until you act on it.** It stays on screen until it
is tapped or dismissed, or until the next reminder for the same dose
replaces it.
### iPhone — Add to Home Screen first
@@ -141,11 +133,11 @@ permission, and push works.
### The inherent limit
The installed PWA gets reminders, the taken-clears behaviour, and the app
badge — but it **cannot** show a native iOS lock-screen Live Activity
widget. That single feature rides Apple Push Notification service and is
only available through a native iOS build with APNs. Reminders themselves
arrive on both paths; only the Live Activity is APNs-exclusive.
The installed PWA gets reminders, but it **cannot** show a native iOS
lock-screen Live Activity widget. That single feature rides Apple Push
Notification service and is only available through a native iOS build with
APNs. Reminders themselves arrive on both paths; only the Live Activity is
APNs-exclusive.
## Telegram
@@ -586,14 +578,14 @@ it registers for the mood reminder.
## Which channel should I pick?
| Your setup | Recommended channel |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
| PWA on an iPhone | **Web Push**, but install the PWA via _Add to Home Screen_ first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. |
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
| Your setup | Recommended channel |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
| PWA on an iPhone | **Web Push**, but install the PWA via _Add to Home Screen_ first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs a native build. |
| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. |
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
Mixing channels is fine and common — e.g. Web Push on the laptop and
ntfy on the phone. Each user configures their own set from
+19 -7
View File
@@ -12,12 +12,14 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
*
* 1. A page that is open when the connection drops stays the app and says
* so (the offline strip).
* 2. Opening a page without a connection gets the worker's offline page:
* the word "Offline" and a working retry control, never the browser's
* error. (Signed-in pages are never cached, by design: the proxy pins
* `private, no-store` on them because some carry the record in the
* HTML. So this is what an offline launch of the installed app shows.)
* 3. When the connection returns, that page reloads into the app by
* 2. A tap on a link to another page keeps the open page and repeats the
* hint, instead of trading the readable page for the offline one.
* 3. Opening a page without a connection (a typed URL, a cold start) gets
* the worker's offline page: the word "Offline" and a working retry
* control, never the browser's error. Signed-in pages are never cached:
* the proxy pins `private, no-store` on them because some carry the
* record in the HTML.
* 4. When the connection returns, that page reloads into the app by
* itself.
*
* The account is the populated mobile-sweep account; nothing here writes.
@@ -25,7 +27,7 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("offline, with the service worker", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("an open page says it is offline, a new one opens the offline page, and both recover @critical", async ({
test("an open page stays readable offline, a new one opens the offline page, and both recover @critical", async ({
page,
context,
}) => {
@@ -42,6 +44,16 @@ test.describe("offline, with the service worker", () => {
await context.setOffline(true);
await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible();
// A tap on the dashboard link stays on the open page.
await page.locator('a[href="/"]:visible').first().click();
await expect(
page
.locator("[data-sonner-toast]")
.filter({ hasText: "other pages open once you're back online" }),
).toBeVisible();
expect(new URL(page.url()).pathname).toBe("/medications");
await expect(page.locator("#main-content")).toBeVisible();
const response = await page.goto("/timeline");
expect(response?.status()).toBe(503);
await expect(page.getByText("Offline", { exact: true })).toBeVisible();
+82 -358
View File
@@ -4,45 +4,23 @@ import { expect, test } from "./setup/test";
import { FENCE_OFFLINE_STORAGE_STATE_PATH } from "./setup/test-helpers";
/**
* FENCE-AC-07 — the disk layers cannot serve one record's bytes inside
* another.
* FENCE-AC-07: no record's API answers are kept in CacheStorage, before or
* after a record switch, and the one per-account layer on disk, the IndexedDB
* dashboard snapshot, still belongs to the caller inside another record.
*
* ## Why this is a spec of its own
* Runs in the `chromium-service-worker` project with the worker allowed and
* no route mocks, because the claim is about what the worker itself stores.
* The worker passes every `/api/` request to the network and keeps none of
* them. The snapshot refuses to write or restore inside another record
* (unit-tested in `query-persister-shared-record.test.ts`); here it is read
* off the disk after the record's pages were opened.
*
* `playwright.config.ts` sets `serviceWorkers: "block"` for every other
* project, and for a good reason written there: a worker-originated `fetch` is
* not subject to `page.route`, so the SW would bypass the per-spec route mocks.
*
* This case is the exact inverse. Its whole claim is about what the
* `healthlog-data-*` cache can and cannot serve across a record switch — and
* with the worker blocked that cache is never populated at all, so the positive
* control ("the owner snapshot really WAS on disk before the switch") could
* never pass no matter what the application did. A check that cannot pass is
* worse than no check, so this runs in the `chromium-service-worker` project
* with the worker allowed, and uses no route mocks.
*
* ## What is asserted, in order
*
* 1. the owner's record reads really are on disk before the switch — a test
* that seeded nothing proves nothing;
* 2. only current-version caches exist, which is the eviction the fence's
* design relies on to make "a response with no echo" safe;
* 3. the switch empties the data cache before it reloads;
* 4. every response now on disk carries the TARGET's record scope and never
* the previous one — the claim, read off the disk layer itself rather
* than inferred from what a page did or did not paint;
* 5. and offline, that cache is what answers a record read, still carrying
* the target's context. The non-empty scope set is the paired positive
* control: an empty cache would satisfy "no previous-record bytes" while
* proving nothing.
* The positive control is the static cache (install precache): it proves the
* worker installed and that the cache read works, so "no API entry" is not
* the same sentence as "the read saw nothing".
*/
/**
* Pages whose reads the service worker is configured to hold on disk
* (`sw-cache-policy.ts` allowlists `/api/dashboard/snapshot`,
* `/api/measurements` and `/api/medications`). Visited rather than fetched, so
* the requests are the app's own and carry the record-session assertion.
*/
/** Pages whose reads used to be kept on disk. Visited, so the reads are the app's own. */
const WARM_PAGES = ["/", "/measurements", "/medications"] as const;
/**
@@ -105,45 +83,62 @@ async function expectShellReady(page: Page) {
});
}
/** What the `healthlog-data-*` caches hold right now. */
async function dataCacheState(page: Page) {
/** Every HealthLog cache and the API answers it holds. */
async function cacheState(page: Page) {
return page.evaluate(async () => {
const names = await caches.keys();
const data = names.filter((n) => n.startsWith("healthlog-data-"));
const names = (await caches.keys()).filter((n) =>
n.startsWith("healthlog-"),
);
const apiEntries: string[] = [];
let entries = 0;
for (const name of data) {
entries += (await (await caches.open(name)).keys()).length;
for (const name of names) {
for (const request of await (await caches.open(name)).keys()) {
entries += 1;
const { pathname } = new URL(request.url);
if (pathname.startsWith("/api/")) apiEntries.push(pathname);
}
}
return { names, dataCaches: data, entries };
return { names, entries, apiEntries };
});
}
/**
* The record context every cached response was stored WITH.
*
* This is the disk-layer claim, read off the disk layer. A cached record
* response carries the echo it was served under, so if a previous record's
* bytes were still reachable they would show up here as that record's scope.
* The owner the IndexedDB dashboard snapshot records, or `absent`. Opens the
* store the way the app does, so a database this read creates has the shape
* the app expects.
*/
async function cachedScopes(page: Page): Promise<string[]> {
return page.evaluate(async () => {
const scopes: string[] = [];
for (const name of (await caches.keys()).filter((n) =>
n.startsWith("healthlog-data-"),
)) {
const cache = await caches.open(name);
for (const request of await cache.keys()) {
const cached = await cache.match(request);
const scope = cached?.headers.get("x-healthlog-record-scope");
if (scope) scopes.push(scope);
}
}
return scopes;
});
async function snapshotOwner(page: Page): Promise<string | null | "absent"> {
return page.evaluate(
() =>
new Promise<string | null | "absent">((resolve) => {
const request = indexedDB.open("healthlog-query-cache", 1);
request.onupgradeneeded = () => {
request.result.createObjectStore("kv");
};
request.onerror = () => resolve("absent");
request.onsuccess = () => {
const db = request.result;
const read = db
.transaction("kv", "readonly")
.objectStore("kv")
.get("react-query");
read.onsuccess = () => {
db.close();
const payload = read.result as
{ userId?: string | null } | undefined;
resolve(payload ? (payload.userId ?? null) : "absent");
};
read.onerror = () => {
db.close();
resolve("absent");
};
};
}),
);
}
test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({
test("FENCE-AC-07 no record's API answers are kept on disk across a switch", async ({
browser,
}) => {
const context = await browser.newContext({
@@ -153,10 +148,6 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
try {
await page.goto("/");
// The gate is spelled out here rather than left inside a helper because
// the next act is a one-shot `page.evaluate`, and
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
// text between the navigation and the read.
await expect(
page.getByRole("button", { name: "User menu" }),
).toBeVisible();
@@ -168,148 +159,62 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
await page.locator('[data-slot="shared-record-banner-exit"]').click();
await expect(banner).toHaveCount(0);
}
// Wait for the worker to control the page — until it does, nothing it
// would cache is going through it.
await page.waitForFunction(
() => navigator.serviceWorker?.controller !== null,
undefined,
{ timeout: 20_000 },
);
// Warm the disk cache by VISITING the pages, not by issuing bare
// fetches. A bare `fetch` in `page.evaluate` carries no record-session
// assertion, so on a session that has been inside a shared record the
// fence refuses it and nothing cacheable comes back — the cache filled
// from the app's own reads instead, and those were the ones that happened
// to resolve no record scope. Driving the app is what puts real,
// echo-carrying record responses on disk.
for (const path of WARM_PAGES) {
await page.goto(path);
// Spelled out rather than left inside `expectShellReady`, because the
// next act is a one-shot `page.evaluate` and
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
// text between the navigation and the read. A gate hidden in a helper
// is invisible to that guard and to the next reader.
// text between the navigation and the read.
await expect(
page.getByRole("button", { name: "User menu" }),
).toBeVisible();
await expectShellReady(page);
}
// POSITIVE CONTROL: the install precache is there and readable.
await expect
.poll(async () => (await dataCacheState(page)).entries, {
.poll(async () => (await cacheState(page)).entries, {
timeout: 15_000,
})
.toBeGreaterThan(0);
// (1) POSITIVE CONTROL: the owner's bytes really are on disk.
const seeded = await dataCacheState(page);
expect(seeded.dataCaches.length).toBeGreaterThan(0);
expect(seeded.entries).toBeGreaterThan(0);
// (2) Only current-version caches survive activation. This is the
// eviction the fence's "a response with no echo is served normally" rule
// depends on: a PRE-fence cached response carries no echo, and it can
// only be served if its cache is still there.
const version = seeded.dataCaches[0].replace("healthlog-data-", "");
const stale = seeded.names.filter(
(n) =>
/^healthlog-(static|pages|data)-/.test(n) && !n.endsWith(version),
const own = await cacheState(page);
expect(own.apiEntries).toEqual([]);
expect(own.names.filter((n) => n.startsWith("healthlog-data-"))).toEqual(
[],
);
expect(stale).toEqual([]);
await openSwitcher(page);
const entry = page.locator(
`[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`,
const ownId = await page.evaluate(
async () =>
(
(await (await fetch("/api/auth/me")).json()) as {
data: { id: string };
}
).data.id,
);
await expect(entry).toBeVisible();
const targetAccountId = await entry.getAttribute("data-account-id");
expect(targetAccountId).not.toBeNull();
await entry.click();
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
// (3) NOT asserted: "the data cache is momentarily empty".
//
// The switch does wipe the disk layers — `clearOfflineCachesForRecordSwitch`
// runs before the reload — but the reload immediately refills them from
// the target's own reads, so an empty cache is a state that exists for a
// few milliseconds and cannot be sampled reliably. Polling for it made
// this case fail for a reason that had nothing to do with the claim.
//
// The claim is asserted directly below instead, and it is the stronger
// statement anyway: what matters is not that the cache was briefly empty
// but that nothing in it belongs to the previous record.
// Visit inside the TARGET record so there is something of its own on
// disk to find.
await enterRecord(page);
for (const path of WARM_PAGES) {
await page.goto(path);
// Spelled out rather than left inside `expectShellReady`, because the
// next act is a one-shot `page.evaluate` and
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
// text between the navigation and the read. A gate hidden in a helper
// is invisible to that guard and to the next reader.
await expect(
page.getByRole("button", { name: "User menu" }),
).toBeVisible();
await expectShellReady(page);
}
await expect
.poll(async () => (await dataCacheState(page)).entries, {
timeout: 15_000,
})
.toBeGreaterThan(0);
// (4) + (5) The disk layer now holds the TARGET's bytes and only those,
// read off the disk layer itself.
//
// The first draft asserted the shared-record banner after an offline
// reload. That could never pass and had nothing to do with the claim:
// `/api/auth/me` is not a cached read, so offline it fails, `useAuth`
// errors and the shell paints its offline gate rather than a banner. The
// banner's absence says nothing about what is on disk.
//
// What IS on disk is checkable, and it is the whole point: every cached
// record response carries the echo it was served under, so a previous
// record's bytes surviving the switch would appear here as that record's
// scope.
const scopes = await cachedScopes(page);
// POSITIVE CONTROL: something really is cached with a context, so the
// assertion below is about a non-empty set.
expect(scopes.length).toBeGreaterThan(0);
for (const scope of scopes) {
expect(scope).toBe(targetAccountId);
}
expect(scopes).not.toContain("self");
// And the target's own record read really is one of the entries on disk,
// so the scope assertion above is about the reads that matter rather
// than about some incidental cached response.
const cachedUrls = await page.evaluate(async () => {
const urls: string[] = [];
for (const name of (await caches.keys()).filter((n) =>
n.startsWith("healthlog-data-"),
)) {
for (const request of await (await caches.open(name)).keys()) {
urls.push(new URL(request.url).pathname);
}
}
return urls;
});
expect(cachedUrls).toContain("/api/measurements");
// NOT asserted: that a bare `fetch` from page context can read those
// bytes back while offline. Since v1.37.0 an echoing response also
// carries `Vary: x-healthlog-record-epoch, x-healthlog-record-scope`, and
// the Cache API honours `Vary` — so a request WITHOUT the fence headers
// does not match the stored entry at all. That is the correct behaviour
// and a second line of defence worth naming: bytes cached under one
// record context cannot be retrieved by a request asserting a different
// one, or none. The app's own reads carry the headers and do match.
const inside = await cacheState(page);
expect(inside.apiEntries).toEqual([]);
expect(
inside.names.filter((n) => n.startsWith("healthlog-data-")),
).toEqual([]);
// Inside the record nothing is written, so the snapshot is either
// missing or the caller's own from before the switch.
expect(["absent", ownId]).toContain(await snapshotOwner(page));
} finally {
await context.setOffline(false);
// Hand the shared session back to its own record; see the note in
// `v137-record-session-fence.spec.ts`.
await context.request
@@ -318,185 +223,4 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
await context.close();
}
});
test("FENCE-AC-07 a cached record response cannot be SERVED under a different context", async ({
browser,
}) => {
/**
* The serving path, which the case above does not exercise.
*
* The test above asserts what is IN the cache. That is worth asserting and
* it is not the same claim: an entry can sit on disk under one context and
* still be handed to a request made under another, and on the EXTERNAL
* switch path nothing wipes it — the app never initiated the switch, so
* `clearOfflineCachesForRecordSwitch` never runs. On that path `Vary` is
* the only thing standing between a cached delegated response and the next
* request for the same URL.
*
* So this asks the Cache API the question a service worker asks it:
* `cache.match(request)` honours the stored response's `Vary`, so a request
* whose fence headers differ from the ones the entry was stored with does
* not match at all. Remove the `Vary` block from `attachRecordContextEcho`
* and the second lookup starts hitting.
*/
const context = await browser.newContext({
storageState: FENCE_OFFLINE_STORAGE_STATE_PATH,
});
const page = await context.newPage();
try {
await page.goto("/");
await expect(
page.getByRole("button", { name: "User menu" }),
).toBeVisible();
await expectShellReady(page);
const banner = page.locator('[data-slot="shared-record-banner"]');
if ((await banner.count()) > 0) {
await page.locator('[data-slot="shared-record-banner-exit"]').click();
await expect(banner).toHaveCount(0);
}
await page.waitForFunction(
() => navigator.serviceWorker?.controller !== null,
undefined,
{ timeout: 20_000 },
);
// Enter the record and let its reads land on disk.
await enterRecord(page);
await page.goto("/measurements");
await expect(
page.getByRole("button", { name: "User menu" }),
).toBeVisible();
await expectShellReady(page);
// The context those bytes were stored under, read off the entry itself.
//
// Polled, not sampled once. The service worker writes its `healthlog-data-*`
// entry AFTER the response has been handed to the page, so
// `expectShellReady` returning is not evidence that the write has landed —
// it is evidence that the read arrived. Sampling here read `null` on a
// loaded machine and failed the positive control below, which then said
// "there is no cache entry" about a cache that was about to have one.
// Waiting on the entry itself waits for the signal rather than for a
// number of milliseconds, and it still fails if the entry never appears.
const readStoredEntry = async () =>
page.evaluate(async () => {
for (const name of (await caches.keys()).filter((n) =>
n.startsWith("healthlog-data-"),
)) {
const cache = await caches.open(name);
for (const request of await cache.keys()) {
if (
!new URL(request.url).pathname.startsWith("/api/measurements")
)
continue;
const response = await cache.match(request);
if (!response) continue;
return {
cacheName: name,
url: request.url,
vary: response.headers.get("Vary"),
epoch: response.headers.get("x-healthlog-record-epoch"),
scope: response.headers.get("x-healthlog-record-scope"),
};
}
}
return null;
});
// POSITIVE CONTROL: there really is an entry, stored with a context.
await expect.poll(readStoredEntry, { timeout: 20_000 }).not.toBeNull();
const stored = await readStoredEntry();
expect(stored).not.toBeNull();
expect(stored!.epoch).toMatch(/^\d+$/);
expect(stored!.scope).not.toBe("self");
// The EXTERNAL switch: nothing in the app initiates it, so
// `clearOfflineCachesForRecordSwitch` never runs and the entry above
// survives — the positive control below is what proves that.
//
// Sent with the page's own `fetch` rather than through
// `context.request`. Both are outside the app: the switch UI is what runs
// the wipe, and a bare `fetch` is not it (the service worker returns
// without touching any non-GET, so the request reaches the network
// unaltered). What differs is the socket. Every Playwright API context in
// the runner shares one process-wide keep-alive agent, so this POST could
// be handed a connection the server had already closed on its five-second
// idle timeout, and a POST is never replayed on a fresh one — twice this
// line ended the spec with `read ECONNRESET`, on all three attempts.
const left = await page.evaluate(async () => {
const res = await fetch("/api/account/switch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ accountId: null }),
});
return res.status;
});
expect(left).toBe(200);
// Read back through the page for the same reason the switch above goes
// that way. This was the last call in the file still using the shared
// API context, and it duly failed with `read ECONNRESET` on all three
// attempts once the pooled socket had gone stale. The browser opens its
// own connection, and a GET for the caller's own session is outside the
// app either way.
const now = (await page.evaluate(async () => {
const res = await fetch("/api/auth/me");
return (await res.json()).data.recordSession;
})) as {
epoch: number;
scope: string | null;
};
// The context genuinely moved, or the comparison below is vacuous.
expect(String(now.epoch)).not.toBe(stored!.epoch);
const lookups = await page.evaluate(
async ({
cacheName,
url,
storedEpoch,
storedScope,
nowEpoch,
nowScope,
}) => {
const cache = await caches.open(cacheName);
const ask = async (epoch: string, scope: string) =>
(await cache.match(
new Request(url, {
headers: {
"x-healthlog-record-epoch": epoch,
"x-healthlog-record-scope": scope,
},
}),
)) !== undefined;
return {
underStoredContext: await ask(storedEpoch, storedScope),
underNewContext: await ask(nowEpoch, nowScope),
withNoAssertion:
(await cache.match(new Request(url))) !== undefined,
};
},
{
cacheName: stored!.cacheName,
url: stored!.url,
storedEpoch: stored!.epoch!,
storedScope: stored!.scope!,
nowEpoch: String(now.epoch),
nowScope: now.scope ?? "self",
},
);
// POSITIVE CONTROL: the entry IS servable to the context it belongs to.
// Without this, "the other context misses" would pass for an entry that
// was simply not there.
expect(lookups.underStoredContext).toBe(true);
// The claim. Remove the `Vary` and this starts hitting.
expect(lookups.underNewContext).toBe(false);
expect(lookups.withNoAssertion).toBe(false);
} finally {
await context.request
.post("/api/account/switch", { data: { accountId: null } })
.catch(() => {});
await context.close();
}
});
});
+26 -7
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Für dieses Konto ist bereits eine Authenticator-App eingerichtet. Deaktiviere sie zuerst, um eine neue einzurichten."
}
},
"register": {
"taken": "Dieser Nutzername oder diese E-Mail-Adresse wird bereits verwendet.",
"invite_invalid": "Diese Einladung gilt nicht mehr. Bitte die Person, die dich eingeladen hat, um einen neuen Link."
},
"already_authenticated": "Du bist bereits angemeldet. Melde dich zuerst ab und versuche es dann erneut."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Das KI-Budget für heute ist aufgebraucht, daher wurde nichts gesendet. Morgen steht es wieder bereit."
}
}
},
"passkey": {
"unknown": "Dieser Passkey ist hier nicht registriert. Melde dich auf anderem Weg an.",
"challenge": {
"expired": "Die Anmeldung hat zu lange gedauert oder wurde schon verwendet. Versuche es erneut."
},
"response": {
"invalid": "Die Antwort des Passkeys konnte nicht gelesen werden. Versuche es erneut."
}
},
"oidc_only": "Dieser Server erlaubt nur Single Sign-on. Fahre stattdessen mit deinem Identitätsanbieter fort."
},
"doctorReport": {
"title": "Gesundheitsbericht",
@@ -506,9 +521,9 @@
"backToDashboard": "Zurück zum Dashboard"
},
"offlineBanner": {
"message": "Keine Verbindung: Änderungen können erst gespeichert werden, wenn du wieder online bist.",
"message": "Keine Verbindung: Änderungen können nicht gespeichert werden.",
"saveFailed": "Speichern fehlgeschlagen, du bist offline. Bitte versuche es erneut, sobald du wieder online bist.",
"showingCached": "Zeigt deine zuletzt synchronisierten Daten."
"stayOnPage": "Diese Seite bleibt lesbar, andere Seiten öffnen sich, sobald du wieder online bist."
},
"demoBanner": {
"message": "Demo-Modus: Änderungen werden nicht gespeichert."
@@ -585,6 +600,7 @@
"noAccount": "Noch kein Konto?",
"hasAccount": "Bereits ein Konto?",
"loginFailed": "Anmeldung fehlgeschlagen",
"signOutFailed": "Das Abmelden hat nicht geklappt. Du bist weiterhin angemeldet.",
"registerFailed": "Registrierung fehlgeschlagen",
"invitedBanner": "Du wurdest eingeladen. Erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.",
"alreadySignedIn": {
@@ -668,7 +684,6 @@
"submit": "Übernehmen",
"submitting": "Wird übernommen…",
"errorTaken": "Dieser Benutzername oder diese E-Mail-Adresse ist schon vergeben.",
"errorRateLimit": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut.",
"errorFailed": "Das hat nicht geklappt. Versuche es gleich noch einmal.",
"errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut.",
"ssoContinue": "Weiter mit {provider}",
@@ -690,7 +705,8 @@
"toLogin": "Zur Anmeldung"
},
"deletionPending": "Dieses Konto wird am {date} gelöscht und ist bis dahin gesperrt. Ein Admin dieses Servers kann die Löschung vor diesem Datum rückgängig machen.",
"deletionPendingNoDate": "Dieses Konto ist zur Löschung vorgemerkt und gesperrt. Ein Admin dieses Servers kann die Löschung rückgängig machen, bis sie abgeschlossen ist."
"deletionPendingNoDate": "Dieses Konto ist zur Löschung vorgemerkt und gesperrt. Ein Admin dieses Servers kann die Löschung rückgängig machen, bis sie abgeschlossen ist.",
"signInRateLimited": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut."
},
"dashboard": {
"title": "Dashboard",
@@ -6405,6 +6421,7 @@
"webPush": "Browser-Push",
"webPushDescription": "Erhalte Benachrichtigungen direkt im Browser, auch wenn HealthLog nicht geöffnet ist.",
"webPushNotSupported": "Dein Browser unterstützt keine Push-Benachrichtigungen.",
"webPushInstallFirst": "Auf iPhone und iPad funktionieren Push-Benachrichtigungen nur in der installierten App. Tippe in Safari auf Teilen und dann auf „Zum Home-Bildschirm“, öffne HealthLog vom Home-Bildschirm aus und tippe hier auf „Push aktivieren“.",
"webPushDenied": "Push-Benachrichtigungen wurden blockiert. Erlaube sie in den Browser-Einstellungen.",
"webPushNotConfigured": "Web Push ist serverseitig nicht konfiguriert.",
"webPushSubscribe": "Push aktivieren",
@@ -9284,7 +9301,9 @@
"accountErasureFailedBody": "Die Löschfrist von {username} ist abgelaufen, aber das Löschen des Kontos ist fehlgeschlagen. Es bleibt gesperrt und wird jede Stunde erneut versucht, nach den anderen fälligen Konten. Den Fehler zeigt die Karte der fehlgeschlagenen Jobs unter Administration."
},
"user": {
"telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv."
"telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.",
"webPushTestBody": "Wenn du das siehst, kommen Push-Benachrichtigungen auf diesem Gerät an.",
"webPushTestTitle": "Test-Benachrichtigung von HealthLog"
},
"moodReminder": {
"title": "Tägliche Stimmungs-Erinnerung",
+26 -7
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "An authenticator app is already set up for this account. Disable it first to set up a new one."
}
},
"register": {
"taken": "That username or email address is already in use.",
"invite_invalid": "This invitation is no longer valid. Ask the person who invited you for a new link."
},
"already_authenticated": "You are already signed in. Sign out first, then try again."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Today's AI budget is used up, so nothing was sent. It renews tomorrow."
}
}
},
"passkey": {
"unknown": "This passkey is not registered here. Sign in another way.",
"challenge": {
"expired": "The sign-in took too long or was already used. Try again."
},
"response": {
"invalid": "The passkey's answer could not be read. Try again."
}
},
"oidc_only": "This server only allows single sign-on. Continue with your identity provider instead."
},
"doctorReport": {
"title": "Health Report",
@@ -506,9 +521,9 @@
"backToDashboard": "Back to dashboard"
},
"offlineBanner": {
"message": "No connection: changes can't be saved until you're back online.",
"message": "No connection: changes can't be saved.",
"saveFailed": "Couldn't save, you're offline. Please try again once you're back online.",
"showingCached": "Showing your last synced data."
"stayOnPage": "This page stays readable; other pages open once you're back online."
},
"demoBanner": {
"message": "Demo mode: changes are not saved."
@@ -585,6 +600,7 @@
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"loginFailed": "Login failed",
"signOutFailed": "Could not sign out. You are still signed in.",
"registerFailed": "Registration failed",
"invitedBanner": "You have been invited. Create your account even while registration is otherwise closed.",
"alreadySignedIn": {
@@ -668,7 +684,6 @@
"submit": "Take over",
"submitting": "Taking over…",
"errorTaken": "That username or email address is already in use.",
"errorRateLimit": "Too many attempts. Wait a few minutes and try again.",
"errorFailed": "That did not work. Try again in a moment.",
"errorOffline": "That did not reach the server. Check your connection and try again.",
"ssoContinue": "Continue with {provider}",
@@ -690,7 +705,8 @@
"toLogin": "Go to sign in"
},
"deletionPending": "This account is scheduled for deletion on {date} and is locked until then. An admin of this server can undo the deletion before that date.",
"deletionPendingNoDate": "This account is scheduled for deletion and is locked. An admin of this server can undo the deletion until it completes."
"deletionPendingNoDate": "This account is scheduled for deletion and is locked. An admin of this server can undo the deletion until it completes.",
"signInRateLimited": "Too many attempts. Wait a few minutes and try again."
},
"dashboard": {
"title": "Dashboard",
@@ -6405,6 +6421,7 @@
"webPush": "Browser Push",
"webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.",
"webPushNotSupported": "Your browser does not support push notifications.",
"webPushInstallFirst": "On iPhone and iPad, push notifications only work in the installed app. In Safari, tap Share, then Add to Home Screen, open HealthLog from the Home Screen and tap Enable push here.",
"webPushDenied": "Push notifications are blocked. Allow them in your browser settings.",
"webPushNotConfigured": "Web Push is not configured on the server.",
"webPushSubscribe": "Enable push",
@@ -9284,7 +9301,9 @@
"accountErasureFailedBody": "The deletion window of {username} has ended, but erasing the account failed. It stays locked and is tried again every hour, after the other accounts due. The failing jobs card in Administration has the error."
},
"user": {
"telegramTestBody": "HealthLog: connection successful. Telegram notifications are active."
"telegramTestBody": "HealthLog: connection successful. Telegram notifications are active.",
"webPushTestBody": "If you can see this, push notifications reach this device.",
"webPushTestTitle": "HealthLog test notification"
},
"moodReminder": {
"title": "Daily mood reminder",
+26 -7
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Esta cuenta ya tiene una aplicación de autenticación configurada. Desactívala primero para configurar una nueva."
}
},
"register": {
"taken": "Ese nombre de usuario o correo electrónico ya está en uso.",
"invite_invalid": "Esta invitación ya no es válida. Pide un enlace nuevo a la persona que te invitó."
},
"already_authenticated": "Ya has iniciado sesión. Cierra la sesión primero y vuelve a intentarlo."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "El presupuesto de IA de hoy se ha agotado, así que no se ha enviado nada. Se renueva mañana."
}
}
},
"passkey": {
"unknown": "Esta passkey no está registrada aquí. Inicia sesión de otra forma.",
"challenge": {
"expired": "El inicio de sesión tardó demasiado o ya se usó. Vuelve a intentarlo."
},
"response": {
"invalid": "No se pudo leer la respuesta de la passkey. Vuelve a intentarlo."
}
},
"oidc_only": "Este servidor solo permite el inicio de sesión único. Continúa con tu proveedor de identidad."
},
"doctorReport": {
"title": "Informe de salud",
@@ -506,9 +521,9 @@
"backToDashboard": "Volver al panel"
},
"offlineBanner": {
"message": "Sin conexión: los cambios no se pueden guardar hasta que vuelvas a estar en línea.",
"message": "Sin conexión: los cambios no se pueden guardar.",
"saveFailed": "No se pudo guardar porque estás sin conexión. Inténtalo de nuevo cuando vuelvas a estar en línea.",
"showingCached": "Mostrando tus últimos datos sincronizados."
"stayOnPage": "Esta página se puede seguir leyendo; las demás se abrirán cuando vuelvas a estar en línea."
},
"demoBanner": {
"message": "Modo demo: los cambios no se guardan."
@@ -585,6 +600,7 @@
"noAccount": "¿Aún no tienes cuenta?",
"hasAccount": "¿Ya tienes cuenta?",
"loginFailed": "No se ha podido iniciar sesión",
"signOutFailed": "No se pudo cerrar la sesión. Sigues con la sesión iniciada.",
"registerFailed": "No se ha podido completar el registro",
"invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.",
"alreadySignedIn": {
@@ -668,7 +684,6 @@
"submit": "Asumir",
"submitting": "Asumiendo…",
"errorTaken": "Ese nombre de usuario o correo electrónico ya está en uso.",
"errorRateLimit": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo.",
"errorFailed": "No ha funcionado. Vuelve a intentarlo en un momento.",
"errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo.",
"ssoContinue": "Continuar con {provider}",
@@ -690,7 +705,8 @@
"toLogin": "Ir a iniciar sesión"
},
"deletionPending": "Esta cuenta se eliminará el {date} y está bloqueada hasta entonces. Un administrador de este servidor puede deshacer la eliminación antes de esa fecha.",
"deletionPendingNoDate": "Esta cuenta está programada para eliminarse y está bloqueada. Un administrador de este servidor puede deshacer la eliminación hasta que se complete."
"deletionPendingNoDate": "Esta cuenta está programada para eliminarse y está bloqueada. Un administrador de este servidor puede deshacer la eliminación hasta que se complete.",
"signInRateLimited": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo."
},
"dashboard": {
"title": "Panel",
@@ -6405,6 +6421,7 @@
"webPush": "Push del navegador",
"webPushDescription": "Recibe notificaciones directamente en tu navegador, incluso cuando HealthLog no está abierto.",
"webPushNotSupported": "Tu navegador no admite notificaciones push.",
"webPushInstallFirst": "En iPhone y iPad, las notificaciones push solo funcionan en la app instalada. En Safari, toca Compartir y luego «Añadir a pantalla de inicio», abre HealthLog desde la pantalla de inicio y toca aquí «Activar push».",
"webPushDenied": "Las notificaciones push están bloqueadas. Permítelas en los ajustes de tu navegador.",
"webPushNotConfigured": "Web Push no está configurado en el servidor.",
"webPushSubscribe": "Activar push",
@@ -9284,7 +9301,9 @@
"accountErasureFailedBody": "El plazo de eliminación de {username} ha terminado, pero no se pudo borrar la cuenta. Sigue bloqueada y se vuelve a intentar cada hora, después de las demás cuentas pendientes. El error aparece en la tarjeta de tareas fallidas de Administration."
},
"user": {
"telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas."
"telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas.",
"webPushTestBody": "Si ves esto, las notificaciones push llegan a este dispositivo.",
"webPushTestTitle": "Notificación de prueba de HealthLog"
},
"moodReminder": {
"title": "Recordatorio diario de ánimo",
+26 -7
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Une application d'authentification est déjà configurée pour ce compte. Désactivez-la d'abord pour en configurer une nouvelle."
}
},
"register": {
"taken": "Ce nom d’utilisateur ou cette adresse e-mail est déjà utilisé.",
"invite_invalid": "Cette invitation n’est plus valable. Demandez un nouveau lien à la personne qui vous a invité."
},
"already_authenticated": "Vous êtes déjà connecté. Déconnectez-vous d’abord, puis réessayez."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Le budget IA du jour est épuisé, rien n’a donc été envoyé. Il se renouvelle demain."
}
}
},
"passkey": {
"unknown": "Cette passkey n’est pas enregistrée ici. Connectez-vous d’une autre manière.",
"challenge": {
"expired": "La connexion a pris trop de temps ou a déjà été utilisée. Réessayez."
},
"response": {
"invalid": "La réponse de la passkey n’a pas pu être lue. Réessayez."
}
},
"oidc_only": "Ce serveur n’autorise que l’authentification unique. Continuez plutôt avec votre fournisseur d’identité."
},
"doctorReport": {
"title": "Rapport de santé",
@@ -506,9 +521,9 @@
"backToDashboard": "Retour au tableau de bord"
},
"offlineBanner": {
"message": "Pas de connexion : les modifications ne peuvent pas être enregistrées tant que vous n'êtes pas de nouveau en ligne.",
"message": "Pas de connexion : les modifications ne peuvent pas être enregistrées.",
"saveFailed": "Enregistrement impossible : vous êtes hors ligne. Réessayez une fois de nouveau en ligne.",
"showingCached": "Affichage de vos dernières données synchronisées."
"stayOnPage": "Cette page reste lisible ; les autres s'ouvriront dès que vous serez de nouveau en ligne."
},
"demoBanner": {
"message": "Mode démo : les modifications ne sont pas enregistrées."
@@ -585,6 +600,7 @@
"noAccount": "Pas encore de compte ?",
"hasAccount": "Déjà un compte ?",
"loginFailed": "Échec de la connexion",
"signOutFailed": "La déconnexion a échoué. Votre session est toujours ouverte.",
"registerFailed": "Échec de l’inscription",
"invitedBanner": "Vous avez été invité. Créez votre compte même si les inscriptions sont fermées.",
"alreadySignedIn": {
@@ -668,7 +684,6 @@
"submit": "Reprendre",
"submitting": "Reprise en cours…",
"errorTaken": "Ce nom d'utilisateur ou cette adresse e-mail est déjà utilisé.",
"errorRateLimit": "Trop de tentatives. Attendez quelques minutes puis réessayez.",
"errorFailed": "Cela n'a pas fonctionné. Réessayez dans un instant.",
"errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez.",
"ssoContinue": "Continuer avec {provider}",
@@ -690,7 +705,8 @@
"toLogin": "Aller à la connexion"
},
"deletionPending": "Ce compte sera supprimé le {date} et reste verrouillé d’ici là. Un administrateur de ce serveur peut annuler la suppression avant cette date.",
"deletionPendingNoDate": "Ce compte est programmé pour suppression et il est verrouillé. Un administrateur de ce serveur peut annuler la suppression tant qu’elle n’est pas terminée."
"deletionPendingNoDate": "Ce compte est programmé pour suppression et il est verrouillé. Un administrateur de ce serveur peut annuler la suppression tant qu’elle n’est pas terminée.",
"signInRateLimited": "Trop de tentatives. Attendez quelques minutes puis réessayez."
},
"dashboard": {
"title": "Tableau de bord",
@@ -6405,6 +6421,7 @@
"webPush": "Push navigateur",
"webPushDescription": "Recevez des notifications directement dans votre navigateur, même lorsque HealthLog n’est pas ouvert.",
"webPushNotSupported": "Votre navigateur ne prend pas en charge les notifications push.",
"webPushInstallFirst": "Sur iPhone et iPad, les notifications push ne fonctionnent que dans l’app installée. Dans Safari, touchez Partager puis « Sur l’écran d’accueil », ouvrez HealthLog depuis l’écran d’accueil et touchez ici « Activer le push ».",
"webPushDenied": "Les notifications push sont bloquées. Autorisez-les dans les réglages de votre navigateur.",
"webPushNotConfigured": "Web Push n’est pas configuré sur le serveur.",
"webPushSubscribe": "Activer le push",
@@ -9284,7 +9301,9 @@
"accountErasureFailedBody": "Le délai de suppression de {username} est écoulé, mais l’effacement du compte a échoué. Il reste verrouillé et une nouvelle tentative a lieu chaque heure, après les autres comptes en attente. L’erreur figure dans la carte des tâches en échec, dans Administration."
},
"user": {
"telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives."
"telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives.",
"webPushTestBody": "Si vous voyez ceci, les notifications push arrivent sur cet appareil.",
"webPushTestTitle": "Notification de test HealthLog"
},
"moodReminder": {
"title": "Rappel quotidien d’humeur",
+26 -7
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Per questo account è già configurata un'app di autenticazione. Disattivala prima di configurarne una nuova."
}
},
"register": {
"taken": "Questo nome utente o indirizzo e-mail è già in uso.",
"invite_invalid": "Questo invito non è più valido. Chiedi un nuovo link alla persona che ti ha invitato."
},
"already_authenticated": "Hai già effettuato l’accesso. Prima esci, poi riprova."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Il budget IA di oggi è esaurito, quindi non è stato inviato nulla. Si rinnova domani."
}
}
},
"passkey": {
"unknown": "Questa passkey non è registrata qui. Accedi in un altro modo.",
"challenge": {
"expired": "L’accesso ha richiesto troppo tempo o è già stato usato. Riprova."
},
"response": {
"invalid": "Non è stato possibile leggere la risposta della passkey. Riprova."
}
},
"oidc_only": "Questo server consente solo il single sign-on. Continua invece con il tuo provider di identità."
},
"doctorReport": {
"title": "Rapporto sanitario",
@@ -506,9 +521,9 @@
"backToDashboard": "Torna alla dashboard"
},
"offlineBanner": {
"message": "Nessuna connessione: le modifiche non possono essere salvate finché non torni online.",
"message": "Nessuna connessione: le modifiche non possono essere salvate.",
"saveFailed": "Impossibile salvare: sei offline. Riprova quando torni online.",
"showingCached": "Vengono mostrati i tuoi ultimi dati sincronizzati."
"stayOnPage": "Questa pagina resta leggibile; le altre si aprono quando torni online."
},
"demoBanner": {
"message": "Modalità demo: le modifiche non vengono salvate."
@@ -585,6 +600,7 @@
"noAccount": "Non hai ancora un account?",
"hasAccount": "Hai già un account?",
"loginFailed": "Accesso non riuscito",
"signOutFailed": "Non è stato possibile uscire. La sessione è ancora attiva.",
"registerFailed": "Registrazione non riuscita",
"invitedBanner": "Sei stato invitato: crea il tuo account anche se la registrazione è chiusa.",
"alreadySignedIn": {
@@ -668,7 +684,6 @@
"submit": "Prendi in carico",
"submitting": "Presa in carico…",
"errorTaken": "Questo nome utente o indirizzo email è già in uso.",
"errorRateLimit": "Troppi tentativi. Attendi qualche minuto e riprova.",
"errorFailed": "Non ha funzionato. Riprova tra un momento.",
"errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova.",
"ssoContinue": "Continua con {provider}",
@@ -690,7 +705,8 @@
"toLogin": "Vai all’accesso"
},
"deletionPending": "Questo account verrà eliminato il {date} ed è bloccato fino ad allora. Un amministratore di questo server può annullare l’eliminazione prima di quella data.",
"deletionPendingNoDate": "Questo account è in attesa di eliminazione ed è bloccato. Un amministratore di questo server può annullare l’eliminazione finché non è completata."
"deletionPendingNoDate": "Questo account è in attesa di eliminazione ed è bloccato. Un amministratore di questo server può annullare l’eliminazione finché non è completata.",
"signInRateLimited": "Troppi tentativi. Attendi qualche minuto e riprova."
},
"dashboard": {
"title": "Cruscotto",
@@ -6405,6 +6421,7 @@
"webPush": "Push del browser",
"webPushDescription": "Ricevi notifiche direttamente nel browser, anche quando HealthLog non è aperto.",
"webPushNotSupported": "Il tuo browser non supporta le notifiche push.",
"webPushInstallFirst": "Su iPhone e iPad le notifiche push funzionano solo nell'app installata. In Safari tocca Condividi, poi «Aggiungi alla schermata Home», apri HealthLog dalla schermata Home e tocca qui «Attiva push».",
"webPushDenied": "Le notifiche push sono bloccate. Consentile nelle impostazioni del browser.",
"webPushNotConfigured": "Web Push non è configurato sul server.",
"webPushSubscribe": "Attiva push",
@@ -9284,7 +9301,9 @@
"accountErasureFailedBody": "Il periodo di eliminazione di {username} è terminato, ma la cancellazione dell’account non è riuscita. Resta bloccato e viene ritentata ogni ora, dopo gli altri account in scadenza. L’errore è nella scheda dei job non riusciti in Administration."
},
"user": {
"telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive."
"telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive.",
"webPushTestBody": "Se vedi questo messaggio, le notifiche push arrivano su questo dispositivo.",
"webPushTestTitle": "Notifica di prova di HealthLog"
},
"moodReminder": {
"title": "Promemoria quotidiano dell’umore",
+26 -7
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "이 계정에는 이미 인증 앱이 설정되어 있어요. 새로 설정하려면 먼저 해제하세요."
}
},
"register": {
"taken": "이미 사용 중인 사용자명 또는 이메일 주소예요.",
"invite_invalid": "이 초대는 더 이상 유효하지 않아요. 초대한 분께 새 링크를 요청해 주세요."
},
"already_authenticated": "이미 로그인되어 있어요. 먼저 로그아웃한 다음 다시 시도해 주세요."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "오늘의 AI 예산을 모두 사용해서 아무것도 보내지 않았어요. 내일 다시 채워져요."
}
}
},
"passkey": {
"unknown": "이 패스키는 여기에 등록되어 있지 않아요. 다른 방법으로 로그인해 주세요.",
"challenge": {
"expired": "로그인이 너무 오래 걸렸거나 이미 사용되었어요. 다시 시도해 주세요."
},
"response": {
"invalid": "패스키의 응답을 읽을 수 없어요. 다시 시도해 주세요."
}
},
"oidc_only": "이 서버는 통합 로그인(SSO)만 허용해요. 대신 ID 공급자로 계속해 주세요."
},
"doctorReport": {
"title": "건강 리포트",
@@ -506,9 +521,9 @@
"backToDashboard": "대시보드로 돌아가기"
},
"offlineBanner": {
"message": "연결이 없어요. 온라인으로 돌아올 때까지 저장할 수 없어요.",
"message": "연결이 없어요. 지금은 저장할 수 없어요.",
"saveFailed": "저장하지 못했어요. 오프라인 상태예요. 온라인으로 돌아온 뒤 다시 시도해 주세요.",
"showingCached": "마지막으로 동기화한 데이터를 보여주고 있어요."
"stayOnPage": "이 페이지는 계속 볼 수 있어요. 다른 페이지는 온라인으로 돌아오면 열려요."
},
"demoBanner": {
"message": "데모 모드: 변경 내용은 저장되지 않아요."
@@ -585,6 +600,7 @@
"noAccount": "계정이 없나요?",
"hasAccount": "이미 계정이 있나요?",
"loginFailed": "로그인 실패",
"signOutFailed": "로그아웃하지 못했어요. 아직 로그인되어 있어요.",
"registerFailed": "회원가입 실패",
"invitedBanner": "초대를 받았어요. 회원가입이 닫혀 있어도 계정을 만들 수 있어요.",
"alreadySignedIn": {
@@ -668,7 +684,6 @@
"submit": "넘겨받기",
"submitting": "넘겨받는 중…",
"errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.",
"errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.",
"errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.",
"errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요.",
"ssoContinue": "{provider}(으)로 계속",
@@ -690,7 +705,8 @@
"toLogin": "로그인으로 이동"
},
"deletionPending": "이 계정은 {date}에 삭제될 예정이며 그때까지 잠겨 있어요. 그 전에는 이 서버의 관리자가 삭제를 취소할 수 있어요.",
"deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요."
"deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요.",
"signInRateLimited": "시도 횟수가 너무 많아요. 몇 분 후 다시 시도해 주세요."
},
"dashboard": {
"title": "대시보드",
@@ -6405,6 +6421,7 @@
"webPush": "브라우저 푸시",
"webPushDescription": "HealthLog를 열어 두지 않아도 브라우저로 바로 알림을 받아요.",
"webPushNotSupported": "이 브라우저는 푸시 알림을 지원하지 않아요.",
"webPushInstallFirst": "iPhone과 iPad에서는 설치한 앱에서만 푸시 알림을 받을 수 있어요. Safari에서 공유를 누른 다음 '홈 화면에 추가'를 선택하고, 홈 화면에서 HealthLog를 연 뒤 여기서 '푸시 켜기'를 눌러 주세요.",
"webPushDenied": "푸시 알림이 차단돼 있어요. 브라우저 설정에서 허용해 주세요.",
"webPushNotConfigured": "서버에 Web Push가 설정돼 있지 않아요.",
"webPushSubscribe": "푸시 켜기",
@@ -9284,7 +9301,9 @@
"accountErasureFailedBody": "{username} 계정의 삭제 유예 기간이 끝났지만 계정을 지우지 못했어요. 계정은 계속 잠겨 있고, 삭제할 다른 계정들 다음으로 매시간 다시 시도해요. 오류는 관리자 화면의 실패한 작업 카드에서 볼 수 있어요."
},
"user": {
"telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요."
"telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요.",
"webPushTestBody": "이 알림이 보이면 이 기기에서 푸시 알림을 받을 수 있어요.",
"webPushTestTitle": "HealthLog 테스트 알림"
},
"moodReminder": {
"title": "매일 기분 알림",
+26 -7
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Voor dit account is al een authenticator-app ingesteld. Schakel die eerst uit om een nieuwe in te stellen."
}
},
"register": {
"taken": "Deze gebruikersnaam of dit e-mailadres is al in gebruik.",
"invite_invalid": "Deze uitnodiging is niet meer geldig. Vraag de persoon die je heeft uitgenodigd om een nieuwe link."
},
"already_authenticated": "Je bent al ingelogd. Log eerst uit en probeer het dan opnieuw."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Het AI-budget voor vandaag is op, dus er is niets verzonden. Morgen is het er weer."
}
}
},
"passkey": {
"unknown": "Deze passkey is hier niet geregistreerd. Log op een andere manier in.",
"challenge": {
"expired": "Het inloggen duurde te lang of is al gebruikt. Probeer het opnieuw."
},
"response": {
"invalid": "Het antwoord van de passkey kon niet worden gelezen. Probeer het opnieuw."
}
},
"oidc_only": "Deze server staat alleen single sign-on toe. Ga in plaats daarvan verder met je identiteitsprovider."
},
"doctorReport": {
"title": "Gezondheidsrapport",
@@ -506,9 +521,9 @@
"backToDashboard": "Terug naar het dashboard"
},
"offlineBanner": {
"message": "Geen verbinding: wijzigingen kunnen pas worden opgeslagen als je weer online bent.",
"message": "Geen verbinding: wijzigingen kunnen niet worden opgeslagen.",
"saveFailed": "Opslaan is mislukt, je bent offline. Probeer het opnieuw zodra je weer online bent.",
"showingCached": "Je laatst gesynchroniseerde gegevens worden getoond."
"stayOnPage": "Deze pagina blijft leesbaar; andere pagina's openen zodra je weer online bent."
},
"demoBanner": {
"message": "Demomodus: wijzigingen worden niet opgeslagen."
@@ -585,6 +600,7 @@
"noAccount": "Nog geen account?",
"hasAccount": "Heb je al een account?",
"loginFailed": "Inloggen mislukt",
"signOutFailed": "Uitloggen is mislukt. Je bent nog steeds ingelogd.",
"registerFailed": "Registratie mislukt",
"invitedBanner": "Je bent uitgenodigd. Maak je account aan, ook al is registratie verder gesloten.",
"alreadySignedIn": {
@@ -668,7 +684,6 @@
"submit": "Overnemen",
"submitting": "Overnemen…",
"errorTaken": "Die gebruikersnaam of dat e-mailadres is al in gebruik.",
"errorRateLimit": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.",
"errorFailed": "Dat is niet gelukt. Probeer het zo opnieuw.",
"errorOffline": "De server is niet bereikt. Controleer je verbinding en probeer het opnieuw.",
"ssoContinue": "Doorgaan met {provider}",
@@ -690,7 +705,8 @@
"toLogin": "Naar inloggen"
},
"deletionPending": "Dit account wordt op {date} verwijderd en is tot dan vergrendeld. Een beheerder van deze server kan de verwijdering vóór die datum ongedaan maken.",
"deletionPendingNoDate": "Dit account staat gepland voor verwijdering en is vergrendeld. Een beheerder van deze server kan de verwijdering ongedaan maken tot die voltooid is."
"deletionPendingNoDate": "Dit account staat gepland voor verwijdering en is vergrendeld. Een beheerder van deze server kan de verwijdering ongedaan maken tot die voltooid is.",
"signInRateLimited": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw."
},
"dashboard": {
"title": "Dashboard",
@@ -6405,6 +6421,7 @@
"webPush": "Browserpush",
"webPushDescription": "Ontvang meldingen rechtstreeks in je browser, ook als HealthLog niet geopend is.",
"webPushNotSupported": "Je browser ondersteunt geen pushmeldingen.",
"webPushInstallFirst": "Op iPhone en iPad werken pushmeldingen alleen in de geïnstalleerde app. Tik in Safari op Deel en daarna op 'Zet op beginscherm', open HealthLog vanaf het beginscherm en tik hier op 'Push inschakelen'.",
"webPushDenied": "Pushmeldingen zijn geblokkeerd. Sta ze toe in de instellingen van je browser.",
"webPushNotConfigured": "Web Push is niet geconfigureerd op de server.",
"webPushSubscribe": "Push inschakelen",
@@ -9284,7 +9301,9 @@
"accountErasureFailedBody": "De verwijdertermijn van {username} is afgelopen, maar het wissen van het account is mislukt. Het blijft vergrendeld en wordt elk uur opnieuw geprobeerd, na de andere accounts die aan de beurt zijn. De fout staat op de kaart met mislukte taken in Beheer."
},
"user": {
"telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief."
"telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief.",
"webPushTestBody": "Zie je dit, dan komen pushmeldingen aan op dit apparaat.",
"webPushTestTitle": "Testmelding van HealthLog"
},
"moodReminder": {
"title": "Dagelijkse stemmingsherinnering",
+26 -7
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "To konto ma już skonfigurowaną aplikację uwierzytelniającą. Najpierw ją wyłącz, aby skonfigurować nową."
}
},
"register": {
"taken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.",
"invite_invalid": "To zaproszenie jest już nieważne. Poproś osobę, która cię zaprosiła, o nowy link."
},
"already_authenticated": "Masz już zalogowaną sesję. Najpierw się wyloguj, a potem spróbuj ponownie."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Dzisiejszy budżet AI został wykorzystany, więc nic nie zostało wysłane. Odnowi się jutro."
}
}
},
"passkey": {
"unknown": "Ten passkey nie jest tu zarejestrowany. Zaloguj się w inny sposób.",
"challenge": {
"expired": "Logowanie trwało zbyt długo lub zostało już użyte. Spróbuj ponownie."
},
"response": {
"invalid": "Nie udało się odczytać odpowiedzi passkey. Spróbuj ponownie."
}
},
"oidc_only": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO). Kontynuuj u swojego dostawcy tożsamości."
},
"doctorReport": {
"title": "Raport zdrowotny",
@@ -506,9 +521,9 @@
"backToDashboard": "Powrót do panelu"
},
"offlineBanner": {
"message": "Brak połączenia: zmian nie można zapisać, dopóki nie wrócisz online.",
"message": "Brak połączenia: nie można zapisać zmian.",
"saveFailed": "Nie udało się zapisać, jesteś offline. Spróbuj ponownie, gdy wrócisz online.",
"showingCached": "Wyświetlane są ostatnio zsynchronizowane dane."
"stayOnPage": "Ta strona pozostaje czytelna, inne otworzą się, gdy wrócisz online."
},
"demoBanner": {
"message": "Tryb demo: zmiany nie są zapisywane."
@@ -585,6 +600,7 @@
"noAccount": "Nie masz jeszcze konta?",
"hasAccount": "Masz już konto?",
"loginFailed": "Logowanie nie powiodło się",
"signOutFailed": "Nie udało się wylogować. Sesja jest nadal aktywna.",
"registerFailed": "Rejestracja nie powiodła się",
"invitedBanner": "Otrzymano zaproszenie. Utwórz konto, nawet jeśli rejestracja jest zamknięta.",
"alreadySignedIn": {
@@ -668,7 +684,6 @@
"submit": "Przejmij",
"submitting": "Przejmowanie…",
"errorTaken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.",
"errorRateLimit": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.",
"errorFailed": "Nie udało się. Spróbuj ponownie za chwilę.",
"errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie.",
"ssoContinue": "Kontynuuj przez {provider}",
@@ -690,7 +705,8 @@
"toLogin": "Przejdź do logowania"
},
"deletionPending": "To konto zostanie usunięte {date} i do tego czasu jest zablokowane. Administrator tego serwera może cofnąć usunięcie przed tą datą.",
"deletionPendingNoDate": "To konto czeka na usunięcie i jest zablokowane. Administrator tego serwera może cofnąć usunięcie, dopóki się nie zakończy."
"deletionPendingNoDate": "To konto czeka na usunięcie i jest zablokowane. Administrator tego serwera może cofnąć usunięcie, dopóki się nie zakończy.",
"signInRateLimited": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie."
},
"dashboard": {
"title": "Pulpit",
@@ -6405,6 +6421,7 @@
"webPush": "Push przeglądarki",
"webPushDescription": "Otrzymuj powiadomienia bezpośrednio w przeglądarce, nawet gdy HealthLog nie jest otwarty.",
"webPushNotSupported": "Twoja przeglądarka nie obsługuje powiadomień push.",
"webPushInstallFirst": "Na iPhonie i iPadzie powiadomienia push działają tylko w zainstalowanej aplikacji. W Safari stuknij Udostępnij, potem „Do ekranu początkowego”, otwórz HealthLog z ekranu początkowego i stuknij tutaj „Włącz push”.",
"webPushDenied": "Powiadomienia push są zablokowane. Zezwól na nie w ustawieniach przeglądarki.",
"webPushNotConfigured": "Web Push nie jest skonfigurowany na serwerze.",
"webPushSubscribe": "Włącz push",
@@ -9284,7 +9301,9 @@
"accountErasureFailedBody": "Okres usuwania konta {username} minął, ale usunięcie konta się nie powiodło. Konto pozostaje zablokowane, a próba jest ponawiana co godzinę, po innych kontach do usunięcia. Błąd widać na karcie nieudanych zadań w Administracja."
},
"user": {
"telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne."
"telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne.",
"webPushTestBody": "Jeśli to widzisz, powiadomienia push docierają na to urządzenie.",
"webPushTestTitle": "Powiadomienie testowe HealthLog"
},
"moodReminder": {
"title": "Codzienne przypomnienie o nastroju",
+26 -7
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Esta conta já tem um aplicativo autenticador configurado. Desative-o primeiro para configurar um novo."
}
},
"register": {
"taken": "Esse nome de usuário ou endereço de e-mail já está em uso.",
"invite_invalid": "Este convite não é mais válido. Peça um novo link à pessoa que convidou você."
},
"already_authenticated": "Você já está conectado. Saia primeiro e tente novamente."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "O orçamento de IA de hoje acabou, então nada foi enviado. Ele é renovado amanhã."
}
}
},
"passkey": {
"unknown": "Esta chave de acesso não está registrada aqui. Entre de outra forma.",
"challenge": {
"expired": "O login demorou demais ou já foi usado. Tente novamente."
},
"response": {
"invalid": "Não foi possível ler a resposta da chave de acesso. Tente novamente."
}
},
"oidc_only": "Este servidor permite apenas login único. Continue com seu provedor de identidade."
},
"doctorReport": {
"title": "Relatório de Saúde",
@@ -506,9 +521,9 @@
"backToDashboard": "Voltar ao painel"
},
"offlineBanner": {
"message": "Sem conexão: as alterações não podem ser salvas até você voltar a ficar online.",
"message": "Sem conexão: as alterações não podem ser salvas.",
"saveFailed": "Não foi possível salvar, você está offline. Tente novamente quando voltar a ficar online.",
"showingCached": "Mostrando seus últimos dados sincronizados."
"stayOnPage": "Esta página continua legível; as outras abrem quando você voltar a ficar online."
},
"demoBanner": {
"message": "Modo demonstração: as alterações não são salvas."
@@ -585,6 +600,7 @@
"noAccount": "Não tem uma conta?",
"hasAccount": "Já tem uma conta?",
"loginFailed": "Falha no login",
"signOutFailed": "Não foi possível sair. Sua sessão continua ativa.",
"registerFailed": "Falha no cadastro",
"invitedBanner": "Você foi convidado. Crie sua conta mesmo que o cadastro esteja fechado.",
"alreadySignedIn": {
@@ -668,7 +684,6 @@
"submit": "Assumir",
"submitting": "Assumindo…",
"errorTaken": "Esse nome de usuário ou e-mail já está em uso.",
"errorRateLimit": "Muitas tentativas. Aguarde alguns minutos e tente novamente.",
"errorFailed": "Isso não funcionou. Tente novamente em instantes.",
"errorOffline": "Não foi possível alcançar o servidor. Verifique sua conexão e tente novamente.",
"ssoContinue": "Continuar com {provider}",
@@ -690,7 +705,8 @@
"toLogin": "Ir para o login"
},
"deletionPending": "Esta conta será excluída em {date} e está bloqueada até lá. Um administrador deste servidor pode desfazer a exclusão antes dessa data.",
"deletionPendingNoDate": "Esta conta está programada para exclusão e está bloqueada. Um administrador deste servidor pode desfazer a exclusão até que ela seja concluída."
"deletionPendingNoDate": "Esta conta está programada para exclusão e está bloqueada. Um administrador deste servidor pode desfazer a exclusão até que ela seja concluída.",
"signInRateLimited": "Muitas tentativas. Aguarde alguns minutos e tente novamente."
},
"dashboard": {
"title": "Painel",
@@ -6405,6 +6421,7 @@
"webPush": "Push do navegador",
"webPushDescription": "Receba notificações diretamente no navegador, mesmo quando o HealthLog não estiver aberto.",
"webPushNotSupported": "Seu navegador não oferece suporte a notificações push.",
"webPushInstallFirst": "No iPhone e no iPad, as notificações push só funcionam no app instalado. No Safari, toque em Compartilhar e depois em “Adicionar à Tela de Início”, abra o HealthLog pela Tela de Início e toque aqui em “Ativar push”.",
"webPushDenied": "As notificações push estão bloqueadas. Permita-as nas configurações do navegador.",
"webPushNotConfigured": "O Web Push não está configurado no servidor.",
"webPushSubscribe": "Ativar push",
@@ -9284,7 +9301,9 @@
"accountErasureFailedBody": "O prazo de exclusão de {username} terminou, mas apagar a conta falhou. Ela continua bloqueada e uma nova tentativa é feita a cada hora, depois das outras contas pendentes. O erro aparece no cartão de tarefas com falha em Administração."
},
"user": {
"telegramTestBody": "HealthLog: conexão bem-sucedida. As notificações do Telegram estão ativas."
"telegramTestBody": "HealthLog: conexão bem-sucedida. As notificações do Telegram estão ativas.",
"webPushTestBody": "Se você está vendo isto, as notificações push chegam a este dispositivo.",
"webPushTestTitle": "Notificação de teste do HealthLog"
},
"moodReminder": {
"title": "Lembrete diário de humor",
+26 -7
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Для этого аккаунта уже настроено приложение-аутентификатор. Сначала выключите его, чтобы настроить новое."
}
},
"register": {
"taken": "Это имя пользователя или адрес электронной почты уже занят.",
"invite_invalid": "Это приглашение больше не действует. Попросите у пригласившего новую ссылку."
},
"already_authenticated": "Вы уже вошли. Сначала выйдите, затем попробуйте снова."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Бюджет ИИ на сегодня исчерпан, поэтому ничего не отправлено. Он обновится завтра."
}
}
},
"passkey": {
"unknown": "Этот ключ доступа здесь не зарегистрирован. Войдите другим способом.",
"challenge": {
"expired": "Вход занял слишком много времени или уже был использован. Попробуйте снова."
},
"response": {
"invalid": "Не удалось прочитать ответ ключа доступа. Попробуйте снова."
}
},
"oidc_only": "Этот сервер допускает только единый вход. Продолжите через своего провайдера идентификации."
},
"doctorReport": {
"title": "Отчёт о здоровье",
@@ -506,9 +521,9 @@
"backToDashboard": "Назад к сводке"
},
"offlineBanner": {
"message": "Нет подключения: изменения нельзя сохранить, пока вы снова не в сети.",
"message": "Нет подключения: изменения нельзя сохранить.",
"saveFailed": "Не удалось сохранить, вы не в сети. Попробуйте снова, когда подключение вернётся.",
"showingCached": "Показаны последние синхронизированные данные."
"stayOnPage": "Эту страницу можно читать дальше, другие откроются, когда вы снова будете в сети."
},
"demoBanner": {
"message": "Деморежим: изменения не сохраняются."
@@ -585,6 +600,7 @@
"noAccount": "Нет аккаунта?",
"hasAccount": "Уже есть аккаунт?",
"loginFailed": "Не удалось войти",
"signOutFailed": "Не удалось выйти. Вы по-прежнему в системе.",
"registerFailed": "Не удалось зарегистрироваться",
"invitedBanner": "Вас пригласили. Создайте аккаунт, даже если регистрация в остальном закрыта.",
"alreadySignedIn": {
@@ -668,7 +684,6 @@
"submit": "Принять",
"submitting": "Принимаем…",
"errorTaken": "Это имя пользователя или адрес электронной почты уже используется.",
"errorRateLimit": "Слишком много попыток. Подождите несколько минут и попробуйте снова.",
"errorFailed": "Не сработало. Попробуйте через минуту.",
"errorOffline": "Не дошло до сервера. Проверьте подключение и попробуйте снова.",
"ssoContinue": "Продолжить с {provider}",
@@ -690,7 +705,8 @@
"toLogin": "Перейти ко входу"
},
"deletionPending": "Этот аккаунт запланирован к удалению на {date} и заблокирован до тех пор. Администратор этого сервера может отменить удаление до этой даты.",
"deletionPendingNoDate": "Этот аккаунт запланирован к удалению и заблокирован. Администратор этого сервера может отменить удаление до его завершения."
"deletionPendingNoDate": "Этот аккаунт запланирован к удалению и заблокирован. Администратор этого сервера может отменить удаление до его завершения.",
"signInRateLimited": "Слишком много попыток. Подождите несколько минут и попробуйте снова."
},
"dashboard": {
"title": "Сводка",
@@ -6405,6 +6421,7 @@
"webPush": "Push в браузере",
"webPushDescription": "Получайте уведомления прямо в браузере, даже когда HealthLog не открыт.",
"webPushNotSupported": "Ваш браузер не поддерживает push-уведомления.",
"webPushInstallFirst": "На iPhone и iPad push-уведомления работают только в установленном приложении. В Safari нажмите «Поделиться», затем «На экран „Домой“», откройте HealthLog с экрана «Домой» и нажмите здесь «Включить push».",
"webPushDenied": "Push-уведомления заблокированы. Разрешите их в настройках браузера.",
"webPushNotConfigured": "Web Push не настроен на сервере.",
"webPushSubscribe": "Включить push",
@@ -9284,7 +9301,9 @@
"accountErasureFailedBody": "Срок удаления {username} истёк, но удалить аккаунт не удалось. Он остаётся заблокированным, и попытка повторяется каждый час после остальных аккаунтов с наступившим сроком. Ошибка указана в карточке сбоящих заданий в разделе Администрирование."
},
"user": {
"telegramTestBody": "HealthLog: подключение успешно. Уведомления в Telegram активны."
"telegramTestBody": "HealthLog: подключение успешно. Уведомления в Telegram активны.",
"webPushTestBody": "Если вы видите это сообщение, push-уведомления приходят на это устройство.",
"webPushTestTitle": "Тестовое уведомление HealthLog"
},
"moodReminder": {
"title": "Ежедневное напоминание о настроении",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.47.3",
"version": "1.47.4",
"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",
@@ -0,0 +1,38 @@
-- Turn Web Push back on for every account that has a browser subscription
-- but whose WEB_PUSH channel was switched off by a delivery failure.
--
-- Until this release a send with no subscription left (push turned off on the
-- last device, or the Home Screen app removed) counted as a failure, and five
-- of them, or one 410, switched the channel off. Subscribing again stored the
-- new browser but left the channel off, so the settings card read "enabled",
-- a test push arrived, and no reminder was ever sent. Subscribing now turns
-- the channel back on; this does the same for the accounts already caught.
--
-- Only an automatic switch-off is touched: the dispatcher always writes a
-- `disabled_reason`, a channel switched off by hand has none. The fields
-- reset are the ones a re-enable resets (`reEnableChannel`). A channel
-- without any subscription stays off. Running this again finds nothing left
-- to change.
-- @retry-on-lock-timeout
DO $migration$
BEGIN
PERFORM set_config('lock_timeout', '5s', true);
UPDATE "notification_channels" AS c
SET "enabled" = true,
"disabled_reason" = NULL,
"consecutive_failures" = 0,
"next_retry_at" = NULL,
"last_failure_reason" = NULL,
"updated_at" = CURRENT_TIMESTAMP
WHERE c."type" = 'WEB_PUSH'
AND c."enabled" = false
AND c."disabled_reason" IS NOT NULL
AND EXISTS (
SELECT 1
FROM "push_subscriptions" AS s
WHERE s."user_id" = c."user_id"
);
END
$migration$;
+3 -3
View File
@@ -40,7 +40,7 @@
{
"name": "Add measurement",
"short_name": "Add",
"url": "/measurements?add=WEIGHT&source=shortcut",
"url": "/measurements?add=WEIGHT",
"icons": [
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
]
@@ -48,7 +48,7 @@
{
"name": "Today",
"short_name": "Today",
"url": "/?source=shortcut",
"url": "/",
"icons": [
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
]
@@ -56,7 +56,7 @@
{
"name": "Medications",
"short_name": "Meds",
"url": "/medications?source=shortcut",
"url": "/medications",
"icons": [
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
]
+116 -433
View File
@@ -1,159 +1,52 @@
/**
* Service Worker for HealthLog PWA.
* Handles Web Push notifications and offline caching.
* HealthLog service worker: Web Push, a page cache for public pages, and a
* static cache for catalogs, icons, fonts and images. API answers and app
* chunks are never stored here.
*/
// v1.4.38.4 — `CACHE_VERSION` now tracks the app release tag.
// Bumped on every release so the `activate` step evicts every
// pre-release cache entry. Without this the precached root HTML and
// the cached `/_next/static/*` chunks survived across deploys,
// stale-shell-served the old chunk graph, and the running app then
// hit `ChunkLoadError` on the first lazy navigation. Pair with the
// `<VersionPoller>` client component (mounted in `<Providers>`) that
// polls `/api/version`, compares against `NEXT_PUBLIC_APP_VERSION`,
// and triggers an SW-unregister + cache-wipe + hard reload when the
// server moves ahead of the running shell.
//
// v1.4.43 — read the active version from `self.__APP_VERSION__`,
// which the build step writes to `/public/sw-version.js` (loaded
// synchronously below via `importScripts`). Pre-fix the literal was
// hand-bumped per release and quietly drifted four releases stale, so
// every deploy between v1.4.38.4 → v1.4.42 served the previous shell's
// cache instead of evicting it on activate. The fallback literal stays
// in case the import is missing (legacy SW versions, dev mode without
// the build step), but the source of truth is now the generated file.
// Cache names carry the release, so `activate` drops every older cache. The
// build writes the release to `/sw-version.js`; the literal below is the
// fallback when that file is missing (dev without `prebuild`, a 404), and
// `scripts/generate-sw-version.mjs` rewrites it at the marker on every
// build. Do not edit it by hand.
try {
importScripts("/sw-version.js");
} catch {
// Fall through to the literal fallback below.
// Fall through to the fallback literal.
}
// The `|| "..."` fallback literal is the value used only when the
// `importScripts('/sw-version.js')` above is absent (dev with no `prebuild`
// step, or a 404 on the generated file). `scripts/generate-sw-version.mjs`
// rewrites it to the current `package.json` version on every prebuild — the
// marker below is the rewrite anchor — so in any shipped image the literal
// matches the active release and can never drift stale the way it did across
// 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.47.3";
/* @sw-version-fallback */ "v1.47.4";
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/*`
// reads. Network-first: online callers always get the fresh server response
// (and the cache is refreshed behind it), so a read-after-write never serves a
// stale pre-mutation list. Offline (the network fetch throws) the LAST cached
// dashboard/series is served instead of empty skeletons forever.
//
// v1.18.6.x — reverted from stale-while-revalidate, which served the cached
// pre-mutation copy first and only revalidated in the background, so a list
// read straight after a create/update showed the old (or empty) data until the
// next paint. Network-first restores read-after-write freshness while keeping
// the offline fallback.
const DATA_CACHE = `healthlog-data-${CACHE_VERSION}`;
// Keep this ownership contract aligned with
// `clearOfflineCachesForSessionEnd()` in `query-persister.ts`. Cache cleanup
// must cover every current/legacy HealthLog cache without deleting unrelated
// CacheStorage namespaces owned by another app on the same origin.
// Every HealthLog cache, current and legacy, and nothing another app on the
// origin owns. `data` stays in the pattern so caches from releases that still
// kept API reads are deleted on activate. Same pattern as
// `HEALTHLOG_CACHE_NAME_RE` in `src/lib/pwa/sw-cache-policy.ts`.
const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/;
const CURRENT_HEALTHLOG_CACHES = new Set([
STATIC_CACHE,
PAGE_CACHE,
DATA_CACHE,
]);
// Presence marker `src/proxy.ts` sets on every HealthLog page response. See
// `isForeignNavigationResponse` for why the worker needs it and the proxy for
// why it carries no version. Keep the name in lockstep with
// `HEALTHLOG_PAGE_MARKER_HEADER` there.
const CURRENT_HEALTHLOG_CACHES = new Set([STATIC_CACHE, PAGE_CACHE]);
// Set by `src/proxy.ts` on every HealthLog page; same name as there.
const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page";
// Set as soon as a navigation looks like another app (see
// `isForeignNavigationResponse`). While it is set the fetch handler declines
// every request, so the page and its assets come from the network. It is
// cleared again when `confirmForeignOrigin` finds HealthLog after all, and
// stays set for the rest of the worker's life when the origin is confirmed
// foreign and the worker retires.
// While set, the fetch handler declines every request. Set when a page looks
// like another app, cleared unless the probe confirms another app.
let foreignOrigin = false;
// One confirmation at a time: a burst of navigations must not fire a burst of
// probes, or race one probe's "HealthLog after all" against another's retire.
// One confirmation at a time, shared by a burst of navigations.
let foreignOriginCheck = null;
const MAX_STATIC_ENTRIES = 150;
const MAX_PAGE_ENTRIES = 30;
const MAX_DATA_ENTRIES = 60;
// Safe-GET read allowlist. ONLY idempotent reads that render the core views
// and carry NO secret-shaped body. Auth, mutations and token endpoints are
// never listed — and `isCacheableApiResponse` is a second, body-level guard
// (mirrors the idempotency cache's `hlk_`/`hlr_`/`sk-` refusal) so a future
// endpoint that slipped onto the list can still never persist a secret.
// Matched by exact path or `<path>/` / `<path>?` prefix.
// v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`)
// are deliberately NOT cached: `/api/insights` prefix-matched the Coach chat
// endpoint into the disk cache, and neither belongs on a self-hosted health
// PWA's durable storage. Only the dashboard snapshot, the widget layout, the
// measurement reads (incl. the batched daily series), the medication reads,
// and the version probe are eligible.
const API_READ_ALLOWLIST = [
"/api/dashboard/snapshot",
// v1.37.20 — daily digest: the Today rail's offline read. Keep in
// lockstep with src/lib/pwa/sw-cache-policy.ts.
"/api/daily/digest",
"/api/dashboard/widgets",
"/api/measurements",
"/api/medications",
"/api/version",
];
// Defence in depth: even an allowlisted path is refused if its pathname looks
// like an auth/token/secret surface.
const API_DENY_RE = /\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i;
function isAllowlistedApiRead(pathname) {
if (API_DENY_RE.test(pathname)) return false;
return API_READ_ALLOWLIST.some(
(p) =>
pathname === p ||
pathname.startsWith(p + "/") ||
pathname.startsWith(p + "?"),
);
}
// Body-level secret refusal: never persist a body carrying a token/secret-
// shaped pattern even if the path was allowlisted by mistake.
const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/;
function isCacheableApiResponse(response, bodyText) {
if (!response || !response.ok) return false;
const cacheControl = response.headers.get("Cache-Control") || "";
if (/no-store/i.test(cacheControl)) return false;
if (typeof bodyText === "string" && SECRET_BODY_RE.test(bodyText)) {
return false;
}
return true;
}
// Public immutable assets to precache on install. Never include `/`: installing
// while signed in would fetch the authenticated Dashboard document with the
// browser's session cookies and persist that health-bearing HTML in
// CacheStorage for replay after logout.
// Never `/`: installed while signed in, it would store the dashboard HTML.
const PRECACHE_URLS = ["/logo-192.png", "/logo-512.png", "/favicon.svg"];
// ── Install: precache public immutable assets ────────────────────────────────
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(STATIC_CACHE)
.then(async (cache) => {
// An updated worker can reuse the same release-scoped cache when its
// source changes without a version bump. Remove a root document left
// by the previous worker before adding the safe public asset set.
await cache.delete("/");
await cache.addAll(PRECACHE_URLS);
})
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting()),
);
});
// ── Activate: clean up old caches ────────────────────────────────────────────
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
@@ -170,42 +63,27 @@ self.addEventListener("activate", (event) => {
.map((k) => caches.delete(k)),
),
),
// Navigation preload lets the browser start the navigation request
// in parallel with service-worker startup; `networkFirst` consumes
// it via `event.preloadResponse`, shaving SW boot latency off every
// page navigation. Guarded — older engines lack the API.
// The browser starts the navigation while the worker boots;
// `networkFirst` uses `event.preloadResponse`.
self.registration.navigationPreload
? self.registration.navigationPreload.enable()
: Promise.resolve(),
clearLeftoverAppBadge(),
]).then(() => self.clients.claim()),
);
});
// ── Fetch: strategy per resource type ────────────────────────────────────────
self.addEventListener("fetch", (event) => {
// The origin serves another app now (see `retireFromForeignOrigin`): no
// `respondWith`, no cache, the browser's own fetch for everything.
if (foreignOrigin) return;
const { request } = event;
const url = new URL(request.url);
// Only handle same-origin GET requests
if (request.method !== "GET" || url.origin !== self.location.origin) return;
// One-shot auth navigations must reach the server EXACTLY ONCE. The OIDC
// login + callback endpoints are top-level browser navigations (not
// `fetch`/XHR), so with navigation preload enabled (see `activate`) the
// browser has ALREADY dispatched the request by the time this handler runs.
// Returning early WITHOUT `event.respondWith()` — the general `/api/` path
// below does exactly that — makes the browser ALSO run its own default
// navigation fetch, so the request is sent twice. The OIDC authorization
// code is single-use: the first callback redeems it and sets the session,
// the second fails at the IdP and redirects to
// `/auth/login?error=oidc_failed` on an otherwise-successful sign-in.
// Consuming the preload response here (or fetching once when there is none)
// settles the event with a single network request. These responses are
// never cached.
// The OIDC login and callback are navigations the browser has already sent
// as a preload. Declining here would make it send them a second time, and
// the single-use code fails on the second. Answer with the preload (or one
// fetch) and store nothing.
if (url.pathname.startsWith("/api/auth/")) {
event.respondWith(
(async () => (await event.preloadResponse) || fetch(request))(),
@@ -213,52 +91,28 @@ self.addEventListener("fetch", (event) => {
return;
}
// API calls. Allowlisted safe GET reads use network-first so the installed
// PWA always renders fresh server data online (and falls back to the last
// cached copy only when the network fails); everything else (auth,
// mutations, anything not on the list) stays network-only.
if (url.pathname.startsWith("/api/")) {
if (isAllowlistedApiRead(url.pathname)) {
event.respondWith(networkFirstApi(request));
}
return;
}
// Every other API request goes to the network untouched: no API answer is
// kept on this device.
if (url.pathname.startsWith("/api/")) return;
// Next.js static assets — cache-first (immutable hashed filenames)
if (url.pathname.startsWith("/_next/static/")) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// Locale-catalog boot script — cache-first. The URL is versioned by the
// catalogs' content hash (`/i18n/<locale>?v=<hash>`, `next.config.ts`),
// so a changed catalog is a new URL and cache-first is
// correct and keeps the catalog available on an offline relaunch (the
// cached shell HTML references the same versioned URL it was rendered
// with). Carries no user data — same public strings as the repository's
// messages/*.json.
// Locale catalog: the URL carries the catalogs' content hash, so a change
// is a new URL. Public strings only.
if (url.pathname.startsWith("/i18n/")) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// Static files (fonts, icons, images) — cache-first with long TTL
if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// HTML pages — network-first with cache fallback. The whole event is
// passed so `networkFirst` can consume the navigation-preload response.
if (request.headers.get("accept")?.includes("text/html")) {
event.respondWith(networkFirst(event, PAGE_CACHE));
return;
}
});
/**
* Cache-first: serve from cache, fall back to network and cache the response.
*/
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
@@ -276,80 +130,13 @@ async function cacheFirst(request, cacheName) {
}
/**
* Network-first for allowlisted safe GET `/api/*` reads.
*
* Always tries the network first so an online caller gets the fresh server
* response — critical for read-after-write: a list fetched straight after a
* create/update must reflect the mutation, not a stale pre-mutation copy. On a
* cacheable response the data cache is refreshed behind it. Only when the
* network fails (offline) is the last cached copy served; with neither, a JSON
* 503 lets the client's TanStack Query cache / IndexedDB persister supply data.
*
* Caching is gated twice: `isAllowlistedApiRead` already filtered the path,
* and `isCacheableApiResponse` inspects the actual body so a `no-store` or
* secret-shaped (`hlk_`/`hlr_`/`sk-`) response is never persisted — the same
* refusal discipline the idempotency cache uses.
*/
async function networkFirstApi(request) {
try {
const response = await fetch(request);
// Read the body once to body-screen it, then reconstruct a fresh Response
// so both the cache.put and the return value have an unconsumed body.
const bodyText = await response
.clone()
.text()
.catch(() => undefined);
if (isCacheableApiResponse(response, bodyText)) {
await bestEffortCacheWrite(
DATA_CACHE,
request,
new Response(bodyText, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
MAX_DATA_ENTRIES,
);
}
return response;
} catch {
// Network failed (offline) — serve the last cached copy if we have one.
const cache = await caches.open(DATA_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
return new Response(
JSON.stringify({ data: null, error: "offline", meta: { offline: true } }),
{
status: 503,
headers: { "Content-Type": "application/json; charset=utf-8" },
},
);
}
}
/**
* Privacy gate for the navigation page cache. Most app routes are
* client-fetch-only shells with no server-rendered PII (health JSON loads
* over `/api/*`, which is network-only here), so caching their HTML is
* safe. Two carve-outs:
*
* 1. A `Cache-Control: no-store` response opts itself out — the
* principled, future-proof rule for any current/future server RSC
* that renders user data and sets the header.
* 2. `/c/*` (the clinician-share view) renders health values + wellness
* scores straight into the HTML and emits `no-store`; the explicit
* path skip is belt-and-braces so a revoked share can never linger in
* CacheStorage and render back offline.
*
* In practice rule 1 covers every signed-in page: `src/proxy.ts` pins
* `private, no-store` on all of them, because several server-prefetch one
* account's record into the HTML. Only the public pages (sign-in, legal)
* land in this cache, so a navigation to an app page without a connection
* gets the offline page below, never somebody's cached record. A page that
* is already open keeps working offline from the cached chunks and the
* allowlisted data reads.
* Which navigation responses may land in the page cache. Signed-in pages
* never do: the proxy sends them `private, no-store` because some carry one
* account's record in the HTML. Two paths are skipped by name as well: the
* clinician share view `/c/*` (no-store too; a revoked share must never
* render back offline) and `/onboarding/*`. What is left are the public
* pages (sign-in, legal), so a navigation to an app page without a
* connection gets the offline page, never somebody's cached record.
*/
function isCacheableNavigation(request, response) {
const cacheControl = response.headers.get("Cache-Control") || "";
@@ -357,6 +144,11 @@ function isCacheableNavigation(request, response) {
try {
const { pathname } = new URL(request.url);
if (pathname === "/c" || pathname.startsWith("/c/")) return false;
// Public to the proxy, so not no-store, but rendered for a signed-in
// account during its setup.
if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) {
return false;
}
} catch {
// Unparseable URL — fall through; the no-store check already ran.
}
@@ -364,30 +156,11 @@ function isCacheableNavigation(request, response) {
}
/**
* Does this navigation response come from something other than HealthLog?
*
* The worker is registered with scope `/` because HealthLog serves everything
* from `/`. When HealthLog is stopped and a different app starts on the same
* origin (the usual case is `localhost:3000` on a developer machine), this
* worker kept answering: `/_next/static/*` is cache-first, so the other app
* ran on HealthLog's cached chunks, and the only way out was unregistering by
* hand in the browser's developer tools.
*
* Only a response that positively looks like another app counts:
*
* - a redirect is opaque to the worker (`opaqueredirect`, status 0, no
* readable headers), and HealthLog redirects every signed-out page, so it
* proves nothing; status 0 fails the `ok` check below;
* - a non-2xx answer proves nothing either: a reverse proxy or auth gateway
* in front of a stopped HealthLog answers 502 or 401 without the marker,
* and unregistering there would also drop the Web Push subscription;
* - a network failure never reaches this check (it throws into the offline
* fallback in `networkFirst`).
*
* What is left is a successful page without the marker HealthLog's proxy puts
* on every page response. That is a suspicion, not a verdict: a reverse proxy
* with a response-header allowlist strips the marker from every genuine page.
* `confirmForeignOrigin` settles it before anything is deleted.
* Does this page come from another app on the same origin (a developer's
* `localhost:3000`, say)? Only a successful page without the proxy's marker
* counts: a redirect is opaque, and a 401 or 502 from a gateway in front of a
* stopped HealthLog proves nothing. Still only a suspicion, because a proxy
* with a header allowlist strips the marker; `confirmForeignOrigin` decides.
*/
function isForeignNavigationResponse(response) {
if (!response || !response.ok) return false;
@@ -395,30 +168,15 @@ function isForeignNavigationResponse(response) {
}
/**
* Is the origin positively not HealthLog? Asked of `/api/version`, which is
* public (on the proxy's `PUBLIC_PATHS` list, no session needed) and answers
* `{ data: { version, buildSha, ... } }`. A body check survives any proxy that
* rewrites headers.
* Is the origin positively not HealthLog? Asks the public `/api/version`,
* whose body (`data.version` a string, `data.buildSha` present, possibly
* null) survives any header rewriting. A 404, non-JSON or another shape is
* foreign; a thrown fetch, a 5xx, a 401 or a redirect proves nothing.
*
* Retire only on a positive answer from something else: a 404, a 2xx that is
* not JSON (another app's HTML fallback), or JSON without the HealthLog shape.
* `buildSha` is `null` on an image not built by the release workflow, so the
* shape is "`version` is a string and `buildSha` is present", not "both are
* strings". Anything that is not an answer (a thrown fetch, a 5xx, a 401 from
* an auth gateway, a redirect) proves nothing, and the worker resumes normal
* service.
*
* One gateway shape still reads as "another app": a same-origin auth gateway
* that answers an expired session with its sign-in page and status 200 (nginx
* `auth_request` with `error_page 401 = /oauth2/sign_in` does this). Both the
* navigation and this probe then come back as HTML. The probe sends the
* gateway cookie (`credentials: "same-origin"`), so a valid gateway session
* reaches HealthLog; with an expired one the verdict is wrong, which is why
* `retireFromForeignOrigin` never unregisters while a push subscription exists.
*
* The probe is the worker's own `fetch`, which never passes through this
* worker's fetch handler; `no-store` keeps the HTTP cache out of it and
* `redirect: "manual"` keeps a login redirect from reading as an answer.
* An auth gateway that answers an expired session with a 200 sign-in page
* still reads as foreign, which is why `retireFromForeignOrigin` keeps a
* worker that holds a push subscription. The gateway cookie is sent, so a
* valid gateway session reaches HealthLog.
*/
async function confirmForeignOrigin() {
let response;
@@ -448,9 +206,7 @@ async function confirmForeignOrigin() {
return !isHealthlog;
}
/**
* Confirm, then either retire or resume. Shared by concurrent navigations.
*/
/** Confirm, then retire or resume. Shared by concurrent navigations. */
function checkForeignOrigin() {
if (!foreignOriginCheck) {
foreignOriginCheck = (async () => {
@@ -464,8 +220,7 @@ function checkForeignOrigin() {
await retireFromForeignOrigin();
return;
}
// HealthLog after all, or no answer: serve normally again, and let a
// later suspicious navigation ask again.
// HealthLog after all, or no answer: resume, and ask again next time.
foreignOrigin = false;
foreignOriginCheck = null;
})();
@@ -474,11 +229,9 @@ function checkForeignOrigin() {
}
/**
* Whether this registration holds a Web Push subscription. Unregistering
* destroys the subscription and nothing in the app re-creates it (the push
* card only reads it), so reminders would stop without notice. A missing
* `pushManager` or a throwing lookup counts as "has one": keeping a worker
* that only passes requests through costs nothing, losing reminders does.
* Whether this registration holds a Web Push subscription, which
* unregistering would destroy without notice. When unsure, yes: a
* pass-through worker costs nothing, lost reminders do.
*/
async function hasPushSubscription() {
try {
@@ -493,18 +246,11 @@ async function hasPushSubscription() {
}
/**
* Leave an origin that no longer serves HealthLog: delete every HealthLog
* cache (only ours, matched by name, never another app's CacheStorage) and
* unregister, so the next load of that origin runs without this worker. The
* `foreignOrigin` flag set by the caller stays set, so the requests the
* current page still makes go to the network. Each step is best-effort and
* independent: a failed cache delete must not keep the worker registered.
*
* With a push subscription the worker does not unregister: the verdict may
* be a gateway's 200 sign-in page (see `confirmForeignOrigin`), and
* unregistering would destroy the subscription. It deletes the caches and
* stays in pass-through for the rest of its life instead, so another app on
* the origin still gets network content. The next worker start probes again.
* Leave an origin that no longer serves HealthLog: delete HealthLog's caches
* (never another app's) and unregister. With a push subscription it stays
* registered in pass-through instead (`foreignOrigin` stays set), and the
* next worker start probes again. Each step is independent: a failed delete
* must not keep the worker registered.
*/
async function retireFromForeignOrigin() {
try {
@@ -515,7 +261,7 @@ async function retireFromForeignOrigin() {
.map((k) => caches.delete(k)),
);
} catch {
// CacheStorage unavailable; unregistering still matters more.
// CacheStorage unavailable; unregistering matters more.
}
if (await hasPushSubscription()) return;
try {
@@ -526,14 +272,11 @@ async function retireFromForeignOrigin() {
}
/**
* Network-first: try network (preferring the navigation-preload response
* when the browser already started it), fall back to cache.
*
* The offline fallback only serves a shell cached under the CURRENT
* `CACHE_VERSION` cache names. The previous global `caches.match()` could
* resolve from a stale pre-update cache in the activation gap and serve a
* shell whose `/_next/static/*` chunk graph no longer exists
* (`ChunkLoadError` on the first lazy navigation).
* Network-first for pages: the navigation-preload response when the browser
* already started it, else a fetch. Without a connection, a page from this
* release's page cache (only public pages are there), else the offline page.
* The lookup is scoped to this release's cache name: a page from an older
* release would reference chunks that no longer exist.
*/
async function networkFirst(event, cacheName) {
const { request } = event;
@@ -542,8 +285,7 @@ async function networkFirst(event, cacheName) {
(event.preloadResponse ? await event.preloadResponse : null) ||
(await fetch(request));
if (isForeignNavigationResponse(response)) {
// Step aside at once (declining a request only sends it to the
// network), but delete nothing until the origin is confirmed foreign.
// Step aside at once; delete nothing until it is confirmed.
foreignOrigin = true;
event.waitUntil(checkForeignOrigin());
return response;
@@ -560,28 +302,8 @@ async function networkFirst(event, cacheName) {
}
return response;
} catch {
// Version-scoped fallback: the page cache first, then the precached
// app shell — both names embed CACHE_VERSION, so a stale shell from a
// previous release can never be served here.
const pageCache = await caches.open(cacheName);
const cachedPage = await pageCache.match(request);
const cachedPage = await (await caches.open(cacheName)).match(request);
if (cachedPage) return cachedPage;
const staticCache = await caches.open(STATIC_CACHE);
const precached = await staticCache.match(request);
if (precached) return precached;
// Offline fallback. F-7 (mobile security audit, 2026-05-16): the
// previous fallback rendered hard-coded German body copy, which is
// wrong for any en/* user reaching it offline. The replacement is
// language-neutral — wordmark + the universally understood
// "Offline" token + a generic retry hint expressed as an icon
// (the round-trip arrow), so the page does the right thing in any
// locale without shipping a translation bundle into the worker.
//
// v1.42 — the arrow is a real control now (a link to the same URL,
// 64 px), the page reloads by itself as soon as the connection is
// back, it follows the device's light or dark scheme, and it keeps
// clear of the notch and home indicator of an installed app.
return new Response(OFFLINE_PAGE, {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
@@ -589,6 +311,9 @@ async function networkFirst(event, cacheName) {
}
}
// Language-neutral, because the worker ships no catalog: the wordmark, the
// word "Offline" and a 64 px retry link. It reloads itself when the
// connection returns, follows the device scheme and keeps clear of the notch.
const OFFLINE_PAGE =
'<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>HealthLog — Offline</title><style>' +
":root{--bg:#282a36;--fg:#f8f8f2;--accent:#bd93f9;--muted:#a4a9c9;color-scheme:dark}" +
@@ -600,10 +325,8 @@ const OFFLINE_PAGE =
'<script>addEventListener("online",function(){location.reload()})</script></body></html>';
/**
* Cache a successful network response without making CacheStorage
* availability part of the fetch contract. Quota, eviction, or trim failures
* are intentionally swallowed: caching may improve a later request, but it
* must never replace this request's already-successful network response.
* Store a response, then trim. Failures (quota, eviction) are swallowed: a
* cache write must never replace a response that already succeeded.
*/
async function bestEffortCacheWrite(
cacheName,
@@ -616,15 +339,11 @@ async function bestEffortCacheWrite(
await cache.put(request, response);
await trimCache(cacheName, maxEntries);
} catch {
// CacheStorage is an optional optimization.
// Optional.
}
}
/**
* Trim cache to maxEntries by removing oldest entries. `keys()` returns
* insertion order, so one read + a single pass over the excess prefix
* replaces the previous re-fetch-keys-per-deletion loop (O(n²) reads).
*/
/** Drop the oldest entries past `maxEntries`; `keys()` is insertion order. */
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
@@ -634,28 +353,16 @@ async function trimCache(cacheName, maxEntries) {
}
}
// ── App badge helper (PWA Badging API) ───────────────────────────────────────
// v1.18.4 — reflect the server-authoritative outstanding-dose count on the
// installed PWA icon. Feature-detected + best-effort: unsupported engines
// (most desktop Firefox, older Safari) silently no-op. A count of 0 clears the
// badge; a positive count sets it. `undefined`/non-number leaves it untouched.
function applyAppBadge(count) {
if (typeof count !== "number" || !isFinite(count)) return;
// Earlier releases set a dose count on the app icon; nothing sends one now,
// so clear what they left. Never fails the activation.
async function clearLeftoverAppBadge() {
try {
if (count > 0) {
if (typeof self.navigator?.setAppBadge === "function") {
self.navigator.setAppBadge(count);
}
} else if (typeof self.navigator?.clearAppBadge === "function") {
self.navigator.clearAppBadge();
}
await self.navigator?.clearAppBadge?.();
} catch {
// Badging API can reject (permission / unsupported) — never let it
// break the push handler.
// Optional.
}
}
// ── Push Notifications ───────────────────────────────────────────────────────
self.addEventListener("push", (event) => {
if (!event.data) return;
@@ -666,66 +373,34 @@ self.addEventListener("push", (event) => {
payload = { title: "HealthLog", body: event.data.text() };
}
// v1.18.4 — `type:"clear"` is the PWA equivalent of ending an iOS Live
// Activity: the server sends it when a dose is logged so the still-pending
// dose-due reminder for that slot is closed here (matched on its stable
// `tag`), and the app badge re-reflects the outstanding-dose count. No new
// notification is shown.
if (payload && payload.type === "clear") {
event.waitUntil(
(async () => {
const tagToClear = payload.tag;
if (tagToClear) {
const matches = await self.registration.getNotifications({
tag: tagToClear,
});
for (const n of matches) n.close();
}
applyAppBadge(payload.badge);
})(),
);
return;
}
const {
title = "HealthLog",
body = "",
tag = "default",
url = "/",
badge,
requireInteraction = false,
} = payload;
event.waitUntil(
Promise.all([
self.registration.showNotification(title, {
body,
tag,
// `renotify` lets a re-fired reminder for the same `tag` re-alert the
// user instead of silently replacing the existing notification.
renotify: true,
// v1.18.4 — urgent events (the web-push sender sets this) keep the
// notification on screen until the user acts, where the browser
// honours it.
requireInteraction: requireInteraction === true,
icon: "/logo-192.png",
badge: "/logo-192.png",
data: { url },
}),
Promise.resolve(applyAppBadge(badge)),
]),
self.registration.showNotification(title, {
body,
tag,
// A re-sent reminder with the same tag alerts again.
renotify: true,
// Set by the sender for urgent events, where the browser honours it.
requireInteraction: requireInteraction === true,
icon: "/logo-192.png",
badge: "/logo-192.png",
data: { url },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
// F-7 (mobile security audit, 2026-05-16): validate the destination
// resolves to the same origin before navigating any client. Push
// payloads are VAPID-signed by our own server, but a server-side bug
// (or compromised admin issuing pushes) could ship an off-origin URL
// into `data.url` and use the focused PWA as a redirect-driven phish.
// Reject anything that doesn't match our origin and fall back to "/".
// Only ever navigate within this origin: a server bug or a compromised
// sender must not turn a tap into a redirect elsewhere.
const rawUrl = event.notification.data?.url || "/";
let safeUrl = "/";
try {
@@ -737,17 +412,25 @@ self.addEventListener("notificationclick", (event) => {
safeUrl = "/";
}
const target = new URL(safeUrl, self.location.origin).href;
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if (client.url.includes(self.location.origin) && "focus" in client) {
client.navigate(safeUrl);
return client.focus();
}
}
return self.clients.openWindow(safeUrl);
const windows = clientList.filter(
(c) => c.url.startsWith(self.location.origin + "/") && "focus" in c,
);
// A window already on the target only needs focus: navigating it
// would reload the app and drop whatever is open there.
const there = windows.find((c) => c.url === target);
if (there) return there.focus();
const client = windows[0];
if (!client) return self.clients.openWindow(safeUrl);
// Focus first, while the tap still counts as user activation.
return client
.focus()
.then(() => client.navigate(safeUrl))
.catch(() => self.clients.openWindow(safeUrl));
}),
);
});
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 93815,
"total": 93816,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -86,6 +86,6 @@
"lib/validations/inbound-documents.ts": 1018,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 933,
"proxy.ts": 845
"proxy.ts": 846
}
}
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 336,
"total": 109357,
"count": 335,
"total": 109080,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -39,7 +39,7 @@
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 224,
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
"app/auth/login/page.tsx :: LoginPage": 567,
"app/auth/register/page.tsx :: RegisterPage": 285,
"app/auth/register/page.tsx :: RegisterPage": 283,
"app/coach/conversations/page.tsx :: CoachConversationsBody": 249,
"app/coach/plans/page.tsx :: CoachPlansBody": 216,
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
@@ -64,7 +64,7 @@
"components/admin/user-management-section.tsx :: UserManagementSection": 630,
"components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217,
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 215,
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
"components/charts/health-chart.tsx :: HealthChart": 462,
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
@@ -136,7 +136,7 @@
"components/labs/lab-history-list.tsx :: LabHistoryList": 527,
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 356,
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378,
"components/layout/auth-shell.tsx :: AuthShell": 518,
"components/layout/auth-shell.tsx :: AuthShell": 483,
"components/layout/bottom-nav.tsx :: BottomNav": 262,
"components/layout/sidebar-nav.tsx :: SidebarNav": 356,
"components/layout/top-bar.tsx :: TopBar": 212,
@@ -217,7 +217,6 @@
"components/settings/sharing-section.tsx :: ShareLinksCard": 260,
"components/settings/sources-section.tsx :: SourcesSection": 601,
"components/settings/thresholds-editor-section.tsx :: MetricRow": 241,
"components/settings/web-push-card.tsx :: WebPushCard": 215,
"components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317,
"components/symptoms/symptoms-section.tsx :: SymptomsSection": 205,
"components/targets/range-bar.tsx :: RangeBar": 215,
@@ -322,9 +321,9 @@
"lib/medications/list-read.ts :: buildMedicationsList": 308,
"lib/medications/scheduling/dose-history.ts :: reconstructDoseHistory": 218,
"lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272,
"lib/notifications/dispatcher.ts :: dispatchNotification": 378,
"lib/notifications/dispatcher.ts :: dispatchNotification": 368,
"lib/notifications/senders/apns.ts :: sendViaApns": 299,
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 255,
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
"lib/personal-records/pr-direction.ts :: getPRDirection": 208,
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261,
+2 -3
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 523,
"total": 5456,
"count": 522,
"total": 5447,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -219,7 +219,6 @@
"app/api/notifications/action/[token]/__tests__/route.test.ts": 5,
"app/api/notifications/status/__tests__/route.test.ts": 4,
"app/api/notifications/web-push/__tests__/route.test.ts": 5,
"app/api/notifications/web-push/test/__tests__/route.test.ts": 9,
"app/api/nutrients/__tests__/route.test.ts": 5,
"app/api/nutrients/batch/__tests__/route.test.ts": 11,
"app/api/nutrients/daily/__tests__/route.test.ts": 7,
@@ -1,7 +1,7 @@
{
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 332,
"total": 332,
"count": 331,
"total": 331,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -329,7 +329,6 @@
"lib/modules/__tests__/surface-module-registry.test.ts": 1,
"lib/mood/__tests__/context-vocabulary.test.ts": 1,
"lib/notifications/__tests__/event-defaults.test.ts": 1,
"lib/pwa/__tests__/sw-cache-policy.test.ts": 1,
"lib/record-activity/__tests__/verb-map-covers-routes.test.ts": 1,
"lib/sharing/__tests__/record-session-fence.test.ts": 1,
"lib/sources/__tests__/source-priority-whoop-applehealth.test.ts": 1,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2872,
"total": 9152,
"count": 2863,
"total": 9124,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -55,7 +55,6 @@
"__tests__/recurrence-course-anchor-guard.test.ts": 1,
"__tests__/relation-links-backup-completeness.test.ts": 1,
"__tests__/resolved-field-consumer-guard.test.ts": 1,
"__tests__/service-worker.test.ts": 2,
"__tests__/session-surface-guard.test.ts": 7,
"__tests__/settings-legacy-routes.test.ts": 1,
"__tests__/shared-record-navigation.test.tsx": 1,
@@ -134,7 +133,7 @@
"app/api/auth/codex/device-start/route.ts": 1,
"app/api/auth/login/__tests__/native-token.test.ts": 2,
"app/api/auth/login/__tests__/route.test.ts": 1,
"app/api/auth/login/route.ts": 5,
"app/api/auth/login/route.ts": 4,
"app/api/auth/me/coach-prefs/route.ts": 7,
"app/api/auth/me/cycle-prefs/route.ts": 1,
"app/api/auth/me/devices/[id]/route.ts": 3,
@@ -554,7 +553,7 @@
"app/api/workouts/route.ts": 2,
"app/auth/claim/page.tsx": 1,
"app/auth/login/page.tsx": 5,
"app/auth/register/page.tsx": 4,
"app/auth/register/page.tsx": 3,
"app/c/[token]/__tests__/page.test.tsx": 2,
"app/c/[token]/d/[id]/__tests__/route.test.ts": 1,
"app/c/[token]/d/[id]/route.ts": 1,
@@ -1084,15 +1083,13 @@
"components/layout/__tests__/capture-picker-discard.test.tsx": 1,
"components/layout/__tests__/capture-picker.test.tsx": 1,
"components/layout/__tests__/nav-model.test.ts": 7,
"components/layout/__tests__/offline-banner.test.tsx": 3,
"components/layout/__tests__/shared-record-chrome.test.tsx": 1,
"components/layout/__tests__/sidebar-nav.test.tsx": 3,
"components/layout/account-switcher-menu.tsx": 1,
"components/layout/auth-shell.tsx": 14,
"components/layout/auth-shell.tsx": 13,
"components/layout/bottom-nav.tsx": 8,
"components/layout/capture-picker.tsx": 4,
"components/layout/nav-model.ts": 17,
"components/layout/offline-banner.tsx": 3,
"components/layout/record-session-fence-bridge.tsx": 1,
"components/layout/shared-record-banner.tsx": 1,
"components/layout/shared-record-grant-loss-bridge.tsx": 1,
@@ -1267,7 +1264,7 @@
"components/onboarding/welcome-screen.tsx": 1,
"components/profile/__tests__/height-entry-surfaces.test.tsx": 1,
"components/profile/height-field-control.tsx": 1,
"components/providers.tsx": 3,
"components/providers.tsx": 2,
"components/records/about-me-note-manager.tsx": 3,
"components/records/ai-profile-inclusion-manager.tsx": 1,
"components/records/allergy-form.tsx": 1,
@@ -1462,7 +1459,7 @@
"hooks/use-account-switch.ts": 2,
"hooks/use-ai-capability.ts": 3,
"hooks/use-ai-run.ts": 1,
"hooks/use-auth.ts": 34,
"hooks/use-auth.ts": 33,
"hooks/use-chart-domain-stats.ts": 1,
"hooks/use-chart-overlay-prefs.ts": 2,
"hooks/use-coach-message-results.ts": 1,
@@ -2495,7 +2492,6 @@
"lib/medications/list-read.ts": 23,
"lib/medications/med-target-map.ts": 1,
"lib/medications/medication-order.ts": 1,
"lib/medications/outstanding-doses.ts": 2,
"lib/medications/route-guards.ts": 1,
"lib/medications/schedule-units-dto.ts": 1,
"lib/medications/scheduled-doses.ts": 1,
@@ -2572,11 +2568,9 @@
"lib/notifications/__tests__/dispatch-localised-cache.test.ts": 1,
"lib/notifications/__tests__/dispatcher.test.ts": 1,
"lib/notifications/__tests__/medication-intake-sync.test.ts": 1,
"lib/notifications/__tests__/web-push-dose-clear.test.ts": 1,
"lib/notifications/delegated-intake.ts": 2,
"lib/notifications/dispatch-localised.ts": 3,
"lib/notifications/dispatcher.ts": 3,
"lib/notifications/dose-tag.ts": 1,
"lib/notifications/medication-intake-sync.ts": 1,
"lib/notifications/senders/__tests__/apns.test.ts": 7,
"lib/notifications/senders/__tests__/push-attempt-record.test.ts": 1,
@@ -2586,10 +2580,9 @@
"lib/notifications/senders/push-attempt-record.ts": 1,
"lib/notifications/senders/telegram-keyboards.ts": 1,
"lib/notifications/senders/telegram.ts": 4,
"lib/notifications/senders/web-push.ts": 5,
"lib/notifications/senders/web-push.ts": 3,
"lib/notifications/senders/webhook.ts": 1,
"lib/notifications/types.ts": 20,
"lib/notifications/web-push-clear.ts": 1,
"lib/nutrients/__tests__/catalog.test.ts": 1,
"lib/nutrients/catalog.ts": 8,
"lib/oauth/signed-state.ts": 1,
@@ -2662,10 +2655,8 @@
"lib/profile/life-circumstances.ts": 1,
"lib/profile/life-derived.ts": 1,
"lib/profile/life-report-rows.ts": 1,
"lib/pwa/__tests__/sw-cache-policy.test.ts": 3,
"lib/pwa/query-client-options.ts": 1,
"lib/pwa/query-persister.ts": 3,
"lib/pwa/sw-cache-policy.ts": 2,
"lib/pwa/query-persister.ts": 2,
"lib/queries/__tests__/use-achievements-query.test.tsx": 1,
"lib/queries/__tests__/use-dashboard-snapshot.test.tsx": 4,
"lib/queries/__tests__/use-medication-compliance-summary.test.ts": 1,
@@ -2872,7 +2863,7 @@
"lib/withings/sync.ts": 6,
"lib/withings/webhook-handler.ts": 3,
"lib/workouts/list-read.ts": 1,
"proxy.ts": 27,
"proxy.ts": 25,
"types/analytics.ts": 8,
"types/backups.ts": 1
}
@@ -347,6 +347,8 @@ const REGISTRY: DestructiveEntry[] = [
"This is the off half of a notification channel toggle. Re-subscribing from the same control recreates the row, nothing the person authored is stored in it, and the browser permission survives. Gating it would put a dialog on a switch.",
},
],
undetected:
"No detector sees it. The DELETE goes out from `lib/notifications/web-push-subscription.ts` (`unsubscribeWebPush`, and `signOutWithoutWebPush` for sign-out), and `lib/` is an excluded root. Registered by hand.",
},
// ── Measurements, mood, labs, illness — the undo-and-restore surfaces ────
@@ -245,7 +245,6 @@ describe("medication intake tracking — intake-row readers honour the switch",
"lib/jobs/coach-nudge.ts",
"lib/insights/general-status.ts",
"lib/analytics/correlations-fast-path.ts",
"lib/medications/outstanding-doses.ts",
]) {
expect(intakeReaders).toContain(known);
}
@@ -161,6 +161,25 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
expect(proxy(makeRequest("/api/search", "DELETE")).status).toBe(403);
});
it("lets a visitor sign out, report page timings and pick a language", () => {
// Each touches only this visitor: their own session, a log line, their
// own language cookie (the locale route leaves the shared account's row
// alone under DEMO_MODE). Refused, sign-out stayed signed in and every
// page load sent the refused locale write and the timings again.
for (const [path, method] of [
["/api/auth/logout", "POST"],
["/api/internal/web-vitals", "POST"],
["/api/auth/me/locale", "PUT"],
] as const) {
expect(
proxy(makeRequest(path, method)).status,
`${method} ${path} is blocked in the demo`,
).not.toBe(403);
}
// The rest of the account's settings stay closed.
expect(proxy(makeRequest("/api/auth/me/timezone", "PUT")).status).toBe(403);
});
it("still blocks a health-data mutation (POST /api/measurements)", () => {
const res = proxy(makeRequest("/api/measurements", "POST"));
expect(res.status).toBe(403);
@@ -87,7 +87,7 @@ describe("proxy early exits carry the baseline security headers", () => {
it("unauthenticated page redirect to /auth/login", () => {
const res = proxy(makeRequest("/dashboard"));
expect(res.headers.get("location")).toMatch(/\/auth\/login$/);
expect(res.headers.get("location")).toMatch(/\/auth\/login(\?|$)/);
expectBaselineHeaders(res);
});
@@ -82,7 +82,7 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
const anonymous = proxy(makeRequest("/insights"));
expect(anonymous.headers.get("location")).toMatch(/\/auth\/login$/);
expect(anonymous.headers.get("location")).toMatch(/\/auth\/login(\?|$)/);
});
it("passes through when hl_onboarding cookie is absent (completed users)", () => {
@@ -123,7 +123,7 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
// were still in place it would short-circuit through public-path
// handling and 200 instead.
expect([307, 308]).toContain(res.status);
expect(res.headers.get("location")).toMatch(/\/auth\/login$/);
expect(res.headers.get("location")).toMatch(/\/auth\/login(\?|$)/);
});
it("still treats subroutes /onboarding/<x> as the onboarding surface", () => {
+40
View File
@@ -0,0 +1,40 @@
/**
* The edge sends a visitor without a session to the sign-in page with the
* page they asked for in `next`, so a home-screen shortcut or a notification
* tap still arrives there after signing in. The sign-in page re-checks the
* value (`sanitizeSameOriginPath`); the proxy only ever puts in the
* request's own path and query.
*/
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
function signInRedirect(pathAndQuery: string): URL {
const res = proxy(new NextRequest(`http://localhost${pathAndQuery}`));
expect([307, 308]).toContain(res.status);
return new URL(res.headers.get("location")!, "http://localhost");
}
describe("proxy.ts sign-in redirect", () => {
it.each([
"/measurements?add=WEIGHT&source=shortcut",
"/mood?add=1",
"/medications",
"/?source=pwa",
])("carries %s to the sign-in page", (target) => {
const location = signInRedirect(target);
expect(location.pathname).toBe("/auth/login");
expect(location.searchParams.get("next")).toBe(target);
});
it("sends the bare start page to a bare sign-in page", () => {
const location = signInRedirect("/");
expect(location.pathname).toBe("/auth/login");
expect(location.search).toBe("");
});
});
+181 -161
View File
@@ -1,19 +1,20 @@
/**
* v1.15.20 — service-worker cache behaviour, evaluated in a `vm` sandbox.
* Service-worker behaviour, evaluated in a `vm` sandbox.
*
* `public/sw.js` is plain script (no module system), so the test boots it
* inside a vm context with a fake CacheStorage and dispatches the captured
* event listeners. Four behaviours are pinned:
* event listeners. Pinned here:
*
* 1. cache write and trim failures never replace a successful network
* response for API, navigation, or static-asset requests;
* 2. the offline HTML fallback only serves a shell cached under the
* CURRENT `CACHE_VERSION` cache names (a stale pre-update shell would
* reference a chunk graph that no longer exists);
* 3. `activate` enables navigation preload and `networkFirst` consumes
* `event.preloadResponse` when the browser supplies it;
* 4. `trimCache` reads the key list once and deletes the excess prefix
* in a single pass (the previous loop re-fetched all keys per delete).
* 1. API requests and `/_next/static/` chunks go to the network untouched,
* and nothing of them lands in CacheStorage;
* 2. cache write and trim failures never replace a successful network
* response for navigation or static-asset requests;
* 3. the offline HTML fallback only serves a page cached under the
* CURRENT `CACHE_VERSION` cache names;
* 4. `activate` deletes every other HealthLog cache, enables navigation
* preload, and `networkFirst` consumes `event.preloadResponse`;
* 5. `trimCache` reads the key list once and deletes the excess prefix
* in a single pass.
*
* A HealthLog page response carries the `X-HealthLog-Page` marker the proxy
* sets, so the navigation fixtures below build pages with `healthlogPage()`.
@@ -41,7 +42,10 @@ const FALLBACK_VERSION = /\/\* @sw-version-fallback \*\/\s*"(v[^"]*)"/.exec(
)![1];
const CURRENT_PAGE_CACHE = `healthlog-pages-${FALLBACK_VERSION}`;
const CURRENT_STATIC_CACHE = `healthlog-static-${FALLBACK_VERSION}`;
const CURRENT_DATA_CACHE = `healthlog-data-${FALLBACK_VERSION}`;
// The API cache older releases kept, under the current version's name.
const LEGACY_DATA_CACHE = `healthlog-data-${FALLBACK_VERSION}`;
// A cache-first static asset: the locale catalog boot script.
const CATALOG = "/i18n/en.js?v=abc123";
const PAGE_MARKER = "X-HealthLog-Page";
@@ -154,10 +158,9 @@ function bootServiceWorker(): SwHarness {
navPreload.enabled = true;
},
};
// v1.18.4 — push-handler fakes: a notification store the `getNotifications`
// / `showNotification` calls read/write, and a Badging-API spy.
// The notifications `showNotification` shows, and a Badging-API spy.
const shown: Array<{ title: string; tag?: string; closed: boolean }> = [];
const badge = { value: undefined as number | undefined, cleared: false };
const badge = { cleared: false };
const selfObj = {
addEventListener: (type: string, fn: (event: unknown) => void) => {
listeners.set(type, fn);
@@ -176,22 +179,10 @@ function bootServiceWorker(): SwHarness {
showNotification: async (title: string, opts: { tag?: string }) => {
shown.push({ title, tag: opts?.tag, closed: false });
},
getNotifications: async ({ tag }: { tag?: string }) =>
shown
.filter((n) => !n.closed && (tag === undefined || n.tag === tag))
.map((n) => ({
close: () => {
n.closed = true;
},
})),
},
navigator: {
setAppBadge: (n: number) => {
badge.value = n;
},
clearAppBadge: () => {
clearAppBadge: async () => {
badge.cleared = true;
badge.value = undefined;
},
},
location: { origin: ORIGIN },
@@ -277,18 +268,6 @@ function dispatchNavigationFetch(
return captured;
}
function dispatchApiFetch(harness: SwHarness, path: string): Promise<Response> {
let captured: Promise<Response> | null = null;
harness.listeners.get("fetch")!({
request: new Request(`${ORIGIN}${path}`),
respondWith: (p: Promise<Response>) => {
captured = p;
},
});
if (!captured) throw new Error("fetch handler did not respond");
return captured;
}
function dispatchAssetFetch(
harness: SwHarness,
path: string,
@@ -386,43 +365,37 @@ describe("sw.js — one-shot auth navigations", () => {
await dispatchAuthNavigation(harness, url).response;
await Promise.all(harness.lifetimePromises);
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
expect(await pages.match(`${ORIGIN}${url}`)).toBeUndefined();
const data = await harness.cacheStorage.open(CURRENT_DATA_CACHE);
expect(await data.match(`${ORIGIN}${url}`)).toBeUndefined();
expect(await harness.cacheStorage.match(`${ORIGIN}${url}`)).toBeUndefined();
});
});
describe("sw.js — what stays off the device", () => {
it.each([
"/api/dashboard/snapshot",
"/api/measurements?type=WEIGHT",
"/api/medications",
"/api/daily/digest",
"/api/version",
"/_next/static/chunks/app.js",
])("leaves %s to the network and stores nothing", async (path) => {
const harness = bootServiceWorker();
let fetchCalls = 0;
harness.context.fetch = async () => {
fetchCalls += 1;
return new Response(JSON.stringify({ data: { weightKg: 80 } }));
};
const { responded } = dispatchAnyFetch(harness, path);
await Promise.all(harness.lifetimePromises);
// Declined: the browser fetches it itself, the worker never sees the body.
expect(responded).toBe(false);
expect(fetchCalls).toBe(0);
expect(harness.cacheStorage.stores.size).toBe(0);
});
});
describe("sw.js — best-effort cache writes", () => {
it("returns the original successful API response when cache.put rejects", async () => {
const harness = bootServiceWorker();
const dataCache = await harness.cacheStorage.open(CURRENT_DATA_CACHE);
dataCache.putError = new Error("CacheStorage quota exceeded");
const networkResponse = new Response(
JSON.stringify({ data: { weightKg: 80 }, error: null }),
{
status: 200,
headers: {
"Content-Type": "application/json",
"X-Network-Response": "original",
},
},
);
harness.context.fetch = async () => networkResponse;
const response = await dispatchApiFetch(
harness,
"/api/measurements?type=WEIGHT",
);
expect(response).toBe(networkResponse);
expect(response.status).toBe(200);
expect(response.headers.get("X-Network-Response")).toBe("original");
expect(await response.text()).toBe(
JSON.stringify({ data: { weightKg: 80 }, error: null }),
);
});
it("returns the original successful navigation response when cache.put rejects", async () => {
const harness = bootServiceWorker();
const pageCache = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
@@ -477,39 +450,16 @@ describe("sw.js — best-effort cache writes", () => {
const harness = bootServiceWorker();
const staticCache = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
staticCache.putError = new Error("CacheStorage quota exceeded");
const networkResponse = new Response("immutable chunk", {
const networkResponse = new Response("immutable catalog", {
headers: { "X-Network-Response": "original" },
});
harness.context.fetch = async () => networkResponse;
const response = await dispatchAssetFetch(
harness,
"/_next/static/chunks/app.js",
);
const response = await dispatchAssetFetch(harness, CATALOG);
expect(response).toBe(networkResponse);
expect(response.headers.get("X-Network-Response")).toBe("original");
expect(await response.text()).toBe("immutable chunk");
});
it("returns the original successful API response when cache trimming rejects", async () => {
const harness = bootServiceWorker();
const dataCache = await harness.cacheStorage.open(CURRENT_DATA_CACHE);
dataCache.keysError = new Error("CacheStorage keys unavailable");
const networkResponse = new Response(
JSON.stringify({ data: { weightKg: 80 }, error: null }),
{ headers: { "Content-Type": "application/json" } },
);
harness.context.fetch = async () => networkResponse;
const response = await dispatchApiFetch(harness, "/api/measurements");
expect(response).toBe(networkResponse);
expect(response.status).toBe(200);
expect(await response.text()).toBe(
JSON.stringify({ data: { weightKg: 80 }, error: null }),
);
expect(dataCache.keysCalls).toBe(1);
expect(await response.text()).toBe("immutable catalog");
});
});
@@ -519,15 +469,27 @@ describe("sw.js — activate", () => {
const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1");
await stale.put(`${ORIGIN}/`, new Response("stale shell"));
await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
// API answers an older worker kept, under this version's name and an old one.
await harness.cacheStorage.open(LEGACY_DATA_CACHE);
await harness.cacheStorage.open("healthlog-data-v0.0.1");
await harness.cacheStorage.open("next-app-runtime");
await dispatchActivate(harness);
expect(harness.cacheStorage.stores.has("healthlog-pages-v0.0.1")).toBe(
false,
expect([...harness.cacheStorage.stores.keys()].sort()).toEqual(
[CURRENT_PAGE_CACHE, "next-app-runtime"].sort(),
);
expect(harness.cacheStorage.stores.has(CURRENT_PAGE_CACHE)).toBe(true);
expect(harness.navPreload.enabled).toBe(true);
});
it("clears the dose count an earlier release left on the app icon", async () => {
const harness = bootServiceWorker();
const { badge } = swFakes(harness);
await dispatchActivate(harness);
expect(badge.cleared).toBe(true);
});
});
describe("sw.js — authenticated install cache isolation", () => {
@@ -590,15 +552,6 @@ describe("sw.js — networkFirst offline fallback", () => {
expect(await res.text()).toBe("current shell");
});
it("falls back to the current-version precached shell when the page cache misses", async () => {
const harness = bootServiceWorker();
const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
await statics.addAll(["/"]);
const res = await dispatchNavigationFetch(harness, "/");
expect(await res.text()).toBe("precached:/");
});
it("consumes the navigation-preload response instead of re-fetching", async () => {
const harness = bootServiceWorker();
let fetchCalls = 0;
@@ -664,6 +617,22 @@ describe("sw.js — networkFirst privacy gate", () => {
expect(await pages.match(`${ORIGIN}/c/hls_abc123`)).toBeUndefined();
});
it.each(["/onboarding", "/onboarding/who"])(
"does not cache the signed-in onboarding page %s",
async (path) => {
const harness = bootServiceWorker();
harness.context.fetch = async () => healthlogPage("onboarding step");
const res = await dispatchNavigationFetch(harness, path);
expect(await res.clone().text()).toBe("onboarding step");
await Promise.all(harness.lifetimePromises);
expect(
await harness.cacheStorage.match(`${ORIGIN}${path}`),
).toBeUndefined();
},
);
it("still caches an ordinary navigation response", async () => {
const harness = bootServiceWorker();
harness.context.fetch = async () => healthlogPage("app shell");
@@ -734,10 +703,7 @@ const foreignHtml = () =>
async function seedHealthlogCaches(harness: SwHarness): Promise<void> {
const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
await statics.put(
`${ORIGIN}/_next/static/chunks/app.js`,
new Response("healthlog chunk"),
);
await statics.put(`${ORIGIN}${CATALOG}`, new Response("healthlog catalog"));
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
await pages.put(`${ORIGIN}/`, healthlogPage("healthlog shell"));
const legacy = await harness.cacheStorage.open("healthlog-data-v0.0.1");
@@ -759,9 +725,9 @@ describe("sw.js — foreign origin (#847)", () => {
expect(await res.text()).toBe("fresh shell");
expect(harness.unregisterCalls.count).toBe(0);
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js");
const asset = dispatchAnyFetch(harness, CATALOG);
expect(asset.responded).toBe(true);
expect(await (await asset.response!).text()).toBe("healthlog chunk");
expect(await (await asset.response!).text()).toBe("healthlog catalog");
});
it("retires when the page lacks the marker and the version probe finds another app's HTML", async () => {
@@ -775,9 +741,7 @@ describe("sw.js — foreign origin (#847)", () => {
// The other app's page is returned untouched, and the worker declines
// requests at once, before the probe has answered.
expect(res).toBe(foreignPage);
expect(
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
).toBe(false);
expect(dispatchAnyFetch(harness, CATALOG).responded).toBe(false);
await Promise.all(harness.lifetimePromises);
@@ -790,9 +754,7 @@ describe("sw.js — foreign origin (#847)", () => {
// Still declining after the retire: static, HTML and API all go to the
// network through the browser.
expect(
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
).toBe(false);
expect(dispatchAnyFetch(harness, CATALOG).responded).toBe(false);
expect(
dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded,
).toBe(false);
@@ -877,10 +839,10 @@ describe("sw.js — foreign origin (#847)", () => {
expect(harness.unregisterCalls.count).toBe(0);
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
// Normal service resumed: the cached chunk is served cache-first again.
const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js");
// Normal service resumed: the cached catalog is served cache-first again.
const asset = dispatchAnyFetch(harness, CATALOG);
expect(asset.responded).toBe(true);
expect(await (await asset.response!).text()).toBe("healthlog chunk");
expect(await (await asset.response!).text()).toBe("healthlog catalog");
});
it.each([
@@ -905,9 +867,7 @@ describe("sw.js — foreign origin (#847)", () => {
expect(harness.unregisterCalls.count).toBe(0);
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
expect(
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
).toBe(true);
expect(dispatchAnyFetch(harness, CATALOG).responded).toBe(true);
},
);
@@ -970,9 +930,7 @@ describe("sw.js — foreign origin (#847)", () => {
expect([...harness.cacheStorage.stores.keys()]).toEqual([
"next-app-runtime",
]);
expect(
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
).toBe(false);
expect(dispatchAnyFetch(harness, CATALOG).responded).toBe(false);
expect(
dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded,
).toBe(false);
@@ -1056,9 +1014,7 @@ describe("sw.js — foreign origin (#847)", () => {
expect(res).toBe(response);
expect(harness.unregisterCalls.count).toBe(0);
expect(
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
).toBe(true);
expect(dispatchAnyFetch(harness, CATALOG).responded).toBe(true);
});
it("does not treat an opaque redirect (a signed-out HealthLog page) as foreign", async () => {
@@ -1127,7 +1083,7 @@ describe("sw.js — trimCache", () => {
});
});
// ── push handler (v1.18.4: clear-on-taken tag close + app badge) ─────────────
// ── push handler ─────────────────────────────────────────────────────────────
async function dispatchPush(harness: SwHarness, data: unknown): Promise<void> {
let settled: Promise<unknown> = Promise.resolve();
harness.listeners.get("push")!({
@@ -1142,58 +1098,122 @@ async function dispatchPush(harness: SwHarness, data: unknown): Promise<void> {
function swFakes(harness: SwHarness) {
const selfObj = harness.context.self as unknown as {
__shown: Array<{ title: string; tag?: string; closed: boolean }>;
__badge: { value: number | undefined; cleared: boolean };
__badge: { cleared: boolean };
};
return { shown: selfObj.__shown, badge: selfObj.__badge };
}
describe("sw.js — push handler", () => {
it("shows a reminder with its stable tag and sets the app badge", async () => {
it("shows a reminder with its stable tag", async () => {
const harness = bootServiceWorker();
const { shown, badge } = swFakes(harness);
const { shown } = swFakes(harness);
const tag = "med:med-1:2026-06-18T07:00:00.000Z";
await dispatchPush(harness, {
title: "Time for your dose",
body: "Ramipril 5mg",
tag,
badge: 3,
});
expect(shown).toHaveLength(1);
expect(shown[0].tag).toBe(tag);
expect(badge.value).toBe(3);
});
});
it("a type:clear push closes the matching-tag notification (no new one) and updates the badge", async () => {
// ── notification tap ─────────────────────────────────────────────────────────
interface FakeWindow {
url: string;
focused: number;
navigatedTo: string[];
focus: () => Promise<FakeWindow>;
navigate: (url: string) => Promise<FakeWindow>;
}
function fakeWindow(path: string, navigateFails = false): FakeWindow {
const win: FakeWindow = {
url: `${ORIGIN}${path}`,
focused: 0,
navigatedTo: [],
focus: async () => {
win.focused += 1;
return win;
},
navigate: async (url: string) => {
if (navigateFails) throw new TypeError("not controlled");
win.navigatedTo.push(url);
return win;
},
};
return win;
}
async function tapNotification(
harness: SwHarness,
windows: FakeWindow[],
url: string,
): Promise<string[]> {
const opened: string[] = [];
(harness.context.self as { clients: unknown }).clients = {
claim: async () => {},
matchAll: async () => windows,
openWindow: async (target: string) => {
opened.push(target);
return null;
},
};
let settled: Promise<unknown> = Promise.resolve();
harness.listeners.get("notificationclick")!({
notification: { close: () => {}, data: { url } },
waitUntil: (p: Promise<unknown>) => {
settled = p;
},
});
await settled;
return opened;
}
describe("sw.js — notification tap", () => {
it("only focuses a window that is already on the target", async () => {
const harness = bootServiceWorker();
const { shown, badge } = swFakes(harness);
const tag = "med:med-1:2026-06-18T07:00:00.000Z";
const win = fakeWindow("/medications");
const opened = await tapNotification(harness, [win], "/medications");
await dispatchPush(harness, { title: "dose", tag, badge: 1 });
expect(shown.filter((n) => !n.closed)).toHaveLength(1);
expect(win.focused).toBe(1);
expect(win.navigatedTo).toEqual([]);
expect(opened).toEqual([]);
});
it("prefers the window on the target over the first one", async () => {
const harness = bootServiceWorker();
const other = fakeWindow("/");
const there = fakeWindow("/mood?add=1");
await dispatchPush(harness, { type: "clear", tag, badge: 0 });
await tapNotification(harness, [other, there], "/mood?add=1");
// The pending reminder for that slot is closed; no new notification shown.
expect(shown).toHaveLength(1);
expect(shown[0].closed).toBe(true);
// badge: 0 clears the app badge.
expect(badge.cleared).toBe(true);
expect(there.focused).toBe(1);
expect(other.focused).toBe(0);
expect(other.navigatedTo).toEqual([]);
});
it("a type:clear push only closes its own slot's tag", async () => {
it("focuses and navigates a window that is elsewhere", async () => {
const harness = bootServiceWorker();
const { shown } = swFakes(harness);
const win = fakeWindow("/");
await dispatchPush(harness, { title: "a", tag: "med:a:t1" });
await dispatchPush(harness, { title: "b", tag: "med:b:t2" });
const opened = await tapNotification(harness, [win], "/medications");
await dispatchPush(harness, { type: "clear", tag: "med:a:t1" });
expect(win.focused).toBe(1);
expect(win.navigatedTo).toEqual(["/medications"]);
expect(opened).toEqual([]);
});
const a = shown.find((n) => n.tag === "med:a:t1")!;
const b = shown.find((n) => n.tag === "med:b:t2")!;
expect(a.closed).toBe(true);
expect(b.closed).toBe(false);
it("opens a window when none is open or navigating fails", async () => {
const harness = bootServiceWorker();
expect(await tapNotification(harness, [], "/medications")).toEqual([
"/medications",
]);
expect(
await tapNotification(harness, [fakeWindow("/", true)], "/medications"),
).toEqual(["/medications"]);
});
});
@@ -107,3 +107,15 @@ describe("public/manifest.json PWA identity hygiene (F-7, 2026-05-16)", () => {
expect(manifest.start_url).toBe("/");
});
});
describe("public/manifest.json shortcuts", () => {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")) as {
shortcuts: Array<{ name: string; url: string }>;
};
it("carry no query parameter the app does not read", () => {
for (const { name, url } of manifest.shortcuts) {
expect(url, name).not.toContain("source=");
}
});
});
@@ -184,3 +184,30 @@ describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
expect(res.status).toBe(401);
});
});
describe("POST /api/auth/login — per-address limit", () => {
const original = process.env.DEMO_MODE;
afterEach(() => {
if (original === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = original;
});
function limitAsked(): unknown {
const call = vi
.mocked(checkAuthSurfaceRateLimit)
.mock.calls.find((c) => c[1] === "auth:login");
return call?.[2];
}
it("allows five attempts per address on an ordinary instance", async () => {
delete process.env.DEMO_MODE;
await POST(makeRequest());
expect(limitAsked()).toBe(5);
});
it("allows thirty on a demo, whose one account everybody shares", async () => {
process.env.DEMO_MODE = "true";
await POST(makeRequest());
expect(limitAsked()).toBe(30);
});
});
+4 -4
View File
@@ -38,13 +38,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
// v1.4.43 — `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.
// Per IP, or one tighter shared bucket when the trust chain is
// misconfigured. A demo publishes its one account, and a class or an
// office behind one address tries it together: 5 locked them out.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:login",
5,
process.env.DEMO_MODE === "true" ? 30 : 5,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
+4 -2
View File
@@ -66,8 +66,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
// Idempotent: skip the write (and the annotation noise) when the column
// already matches — the mount-time backfill fires on every page load.
if (user.locale !== locale) {
// already matches — the mount-time backfill fires on every page load. A
// demo is one account every visitor shares, so there the choice stays in
// this visitor's cookie and never reaches the row the next visitor reads.
if (user.locale !== locale && process.env.DEMO_MODE !== "true") {
await prisma.user.update({
where: { id: user.id },
data: { locale },
@@ -226,8 +226,13 @@ describe("POST /api/auth/register — first-admin atomicity (M1)", () => {
const res = await postPathThrough(validBody);
expect(res.status).toBe(409);
const body = (await res.json()) as { error: string };
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.error).toBe("Username or email already taken");
// The code the registration page turns into "already in use".
expect(body.meta?.errorCode).toBe("auth.register.taken");
});
});
+7 -3
View File
@@ -24,6 +24,10 @@ import {
} from "@/lib/tz/resolver";
import { isOidcOnly } from "@/lib/auth/oidc";
// One code for a taken username and a taken address, like the one message.
const TAKEN = { errorCode: "auth.register.taken" };
const INVITE_INVALID = { errorCode: "auth.register.invite_invalid" };
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
@@ -144,7 +148,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
prisma.user.findUnique({ where: { username } }),
]);
if (existingEmail || existingUsername) {
return apiError("Username or email already taken", 409);
return apiError("Username or email already taken", 409, TAKEN);
}
// Validate password strength (locale-aware feedback)
@@ -196,7 +200,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
if (!registrationEnabled) {
return apiError("Invalid or expired invite", 403);
return apiError("Invalid or expired invite", 403, INVITE_INVALID);
}
}
}
@@ -249,7 +253,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (user === null) {
return apiError("Username or email already taken", 409);
return apiError("Username or email already taken", 409, TAKEN);
}
// Create session immediately. v1.4.22 W5 reconcile (Sr-H1) —
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
$transaction: vi.fn(),
notificationChannel: {
upsert: vi.fn(),
updateMany: vi.fn(),
},
auditLog: {
create: vi.fn(),
@@ -89,6 +90,9 @@ beforeEach(() => {
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({
id: "ch-1",
} as never);
vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({
count: 0,
});
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// `resetAllMocks` strips the top-level `.mockResolvedValue(...)`
// declaration too, so re-arm the rate-limit success path here.
@@ -217,6 +221,16 @@ describe("POST /api/devices", () => {
};
expect(args.data.apnsToken).toBe("deadbeef".repeat(8));
expect(args.data.apnsEnvironment).toBe("sandbox");
// A reminder with no iPhone paired rests the APNs channel for an hour;
// pairing one ends that rest, so the next reminder does not wait it out.
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
type: "APNS",
lastFailureReason: "apns_no_devices",
},
data: { nextRetryAt: null, lastFailureReason: null },
});
});
it("rejects re-registration of an apnsToken owned by another user with 409", async () => {
+10 -9
View File
@@ -31,6 +31,7 @@ import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { checkRateLimit } from "@/lib/rate-limit";
import { endApnsNoDeviceRest } from "@/lib/notifications/channel-state";
const deviceSchema = z
.object({
@@ -86,15 +87,14 @@ const deviceSchema = z
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Per-user rate cap on device registration. The handler returns a
// distinct 409 (`apns_token_owned_by_other_user` /
// `token_owned_by_other_user`) when an `apnsToken` is already
// bound to a different account — useful for the legitimate
// cross-user-hijack guard, but it also means an authenticated user
// could enumerate token ownership across accounts unboundedly. 20
// device-mutations per 15 minutes is well outside any realistic
// client behaviour (the iOS app registers once on login + on token
// rotation; not 20× / window) while capping enumeration cost.
// Per-user rate cap on device registration. The handler returns a distinct
// 409 (`apns_token_owned_by_other_user` / `token_owned_by_other_user`) when
// an `apnsToken` is already bound to a different account — useful for the
// legitimate cross-user-hijack guard, but it also means an authenticated
// user could enumerate token ownership across accounts unboundedly. 20
// device-mutations per 15 minutes is well outside any realistic client
// behaviour (the iOS app registers once on login + on token rotation; not
// 20× / window) while capping enumeration cost.
const rl = await checkRateLimit(
`devices:register:${user.id}`,
20,
@@ -272,6 +272,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
update: {},
});
await endApnsNoDeviceRest(tx, user.id);
}
return { id: device.id };
@@ -45,10 +45,15 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
},
auditLog: { create: vi.fn() },
pushSubscription: { findMany: vi.fn() },
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/notifications/vapid-config", () => ({
getVapidConfig: vi.fn(),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
@@ -94,15 +99,6 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
// The route dispatches the PWA clear fire-and-forget after a recorded dose;
// both halves are mocked so the test asserts the dispatch, not web-push.
vi.mock("@/lib/notifications/web-push-clear", () => ({
dispatchMedicationIntakeWebClear: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/outstanding-doses", () => ({
countOutstandingDosesToday: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -127,8 +123,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { resolveBearerToken } from "@/lib/auth/bearer";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
const USER = {
id: "user-1",
@@ -210,30 +205,33 @@ describe("POST intake — transport-derived source (iOS #64)", () => {
expect(createdSource()).toBe("API");
});
it("closes the pending dose-due web reminder after a recorded intake", async () => {
// Before this route dispatched the clear, an iOS offline drain (which
// replays free intakes through exactly this endpoint) left the web
// dose-due reminder and the app badge standing after the dose landed.
it("sends no Web Push of its own for a recorded dose", async () => {
// A push that shows no notification gets the subscription revoked by
// Safari after a few. The reminder already on screen stays until it is
// tapped or replaced by the next one for the same slot.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(countOutstandingDosesToday).mockResolvedValue(3);
vi.mocked(getVapidConfig).mockResolvedValue({
subject: "mailto:ops@example.com",
publicKey: "pub",
privateKey: "priv",
});
vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([
{
id: "s1",
userId: USER.id,
endpoint: "https://push.example.com/x",
p256dh: "a",
auth: "b",
userAgent: null,
createdAt: new Date(0),
},
]);
const res = await POST(postReq({}), ROUTE_PARAMS);
expect(res.status).toBe(201);
// Fire-and-forget: the dispatch runs detached from the response.
await expect
.poll(
() => vi.mocked(dispatchMedicationIntakeWebClear).mock.calls.length,
{ timeout: 2_000, interval: 25 },
)
.toBe(1);
expect(
vi.mocked(dispatchMedicationIntakeWebClear).mock.calls[0][0],
).toMatchObject({
userId: "user-1",
medicationId: "med-1",
badgeCount: 3,
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(prisma.pushSubscription.findMany).not.toHaveBeenCalled();
});
it("ignores a client-supplied `source` in the body (no mass assignment)", async () => {
@@ -31,10 +31,16 @@ vi.mock("@/lib/db", () => ({
// asserting the validation refusals they were written for.
appSettings: { findUnique: vi.fn() },
pushSubscription: { upsert: vi.fn(), deleteMany: vi.fn() },
notificationChannel: { findFirst: vi.fn(), create: vi.fn() },
notificationChannel: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
@@ -42,12 +48,45 @@ vi.mock("@/lib/logging/context", () => ({
vi.mock("@/lib/crypto", () => ({ encrypt: (v: string) => `enc(${v})` }));
import type {
NotificationChannel,
PushSubscription,
} from "@/generated/prisma/client";
import { POST, DELETE } from "../route";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
const USER = { id: "user-1", username: "u" };
const STORED_SUBSCRIPTION: PushSubscription = {
id: "sub-1",
userId: USER.id,
endpoint: "https://fcm.googleapis.com/fcm/send/abcdef-routing-secret",
p256dh: "enc(BPkey)",
auth: "enc(authsecret)",
userAgent: null,
createdAt: new Date(0),
};
function channelRow(over: Partial<NotificationChannel>): NotificationChannel {
return {
id: "ch-1",
userId: USER.id,
type: "WEB_PUSH",
enabled: true,
config: "enc({})",
createdAt: new Date(0),
updatedAt: new Date(0),
disabledReason: null,
consecutiveFailures: 0,
lastSuccessAt: null,
lastFailureAt: null,
lastFailureReason: null,
nextRetryAt: null,
...over,
};
}
/** A well-formed subscription, for the cases that should succeed. */
const GOOD = {
endpoint: "https://fcm.googleapis.com/fcm/send/abcdef-routing-secret",
@@ -139,6 +178,109 @@ describe("POST /api/notifications/web-push — the subscribe refusal", () => {
});
});
describe("POST /api/notifications/web-push — the channel it delivers through", () => {
// The channel is switched off when the last browser's endpoint answers 410
// (the app was removed from the Home Screen, site data was cleared) or after
// five failed sends. Subscribing again must switch it back on: before, the
// subscription was stored, the test push arrived and no reminder ever did.
it("switches a disabled channel back on", async () => {
const disabled = channelRow({
enabled: false,
disabledReason: "web_push_410_gone",
consecutiveFailures: 1,
});
vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue(
STORED_SUBSCRIPTION,
);
vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(disabled);
vi.mocked(prisma.notificationChannel.update).mockResolvedValue({
...disabled,
enabled: true,
disabledReason: null,
consecutiveFailures: 0,
});
const res = await (POST as unknown as Handler)(req(GOOD));
expect(res.status).toBe(200);
expect(prisma.notificationChannel.update).toHaveBeenCalledWith({
where: { id: "ch-1" },
data: expect.objectContaining({
enabled: true,
disabledReason: null,
consecutiveFailures: 0,
}),
});
expect(prisma.notificationChannel.create).not.toHaveBeenCalled();
});
it("clears failures and a cooldown left on an enabled channel", async () => {
// Four failed sends before the new subscription: one more transient
// failure would have switched the channel off, and the cooldown skipped
// every reminder until it ran out.
const strained = channelRow({
consecutiveFailures: 4,
nextRetryAt: new Date(Date.now() + 2 * 3_600_000),
lastFailureReason: "web_push_no_subscriptions",
});
vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue(
STORED_SUBSCRIPTION,
);
vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(strained);
vi.mocked(prisma.notificationChannel.update).mockResolvedValue(
channelRow({}),
);
const res = await (POST as unknown as Handler)(req(GOOD));
expect(res.status).toBe(200);
expect(prisma.notificationChannel.update).toHaveBeenCalledWith({
where: { id: "ch-1" },
data: expect.objectContaining({
enabled: true,
consecutiveFailures: 0,
nextRetryAt: null,
lastFailureReason: null,
}),
});
});
it("leaves an enabled channel as it is", async () => {
vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue(
STORED_SUBSCRIPTION,
);
vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(
channelRow({}),
);
const res = await (POST as unknown as Handler)(req(GOOD));
expect(res.status).toBe(200);
expect(prisma.notificationChannel.update).not.toHaveBeenCalled();
expect(prisma.notificationChannel.create).not.toHaveBeenCalled();
});
});
describe("POST /api/notifications/web-push — one account per browser", () => {
it("removes another account's row for the same browser", async () => {
// A session that ended without a sign-out left the previous account's
// row on this endpoint; its reminders kept arriving for the next person.
vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue(
STORED_SUBSCRIPTION,
);
vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(
channelRow({}),
);
const res = await (POST as unknown as Handler)(req(GOOD));
expect(res.status).toBe(200);
expect(prisma.pushSubscription.deleteMany).toHaveBeenCalledWith({
where: { endpoint: GOOD.endpoint, userId: { not: USER.id } },
});
});
});
describe("DELETE /api/notifications/web-push — the unsubscribe refusal", () => {
it("names the offending field rather than answering flat", async () => {
const res = await (DELETE as unknown as Handler)(
+23 -1
View File
@@ -5,6 +5,7 @@ import { isChannelGloballyEnabled } from "@/lib/app-settings";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { encrypt } from "@/lib/crypto";
import { reEnableChannel } from "@/lib/notifications/channel-state";
import { webPushSubscriptionSchema } from "@/lib/validations/notifications";
import { z } from "zod/v4";
@@ -97,7 +98,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// Ensure a WEB_PUSH notification channel exists for this user
// An endpoint is one browser, and the account that subscribed it last owns
// it. A row another account left (a session that ended without a sign-out)
// would keep showing that account's reminders to whoever uses it now.
await prisma.pushSubscription.deleteMany({
where: { endpoint, userId: { not: user.id } },
});
// Ensure an enabled WEB_PUSH channel that is not waiting out a cooldown. A
// channel switched off because the last browser's endpoint died (410) or
// failed five times stays off until something turns it back on, and a new
// subscription is exactly that. Failures counted before it would switch it
// off again after a single later one.
const existingChannel = await prisma.notificationChannel.findFirst({
where: { userId: user.id, type: "WEB_PUSH" },
});
@@ -111,6 +123,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
config: encrypt(JSON.stringify({})),
},
});
} else if (
!existingChannel.enabled ||
existingChannel.consecutiveFailures > 0 ||
existingChannel.nextRetryAt !== null
) {
await reEnableChannel({
id: existingChannel.id,
userId: existingChannel.userId,
type: "WEB_PUSH",
});
}
return apiSuccess({ subscribed: true });
@@ -1,176 +1,201 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
/**
* The Web Push test reaches every subscribed browser of the account, in the
* account's language, through the sender real reminders use.
*
* It used to send to the newest subscription only: with a phone and a laptop
* subscribed, Test pressed on the laptop buzzed the phone and the laptop
* looked broken. The text was English in every locale, and the push went out
* through the library's own transport instead of the pinned `safeFetch` dial.
*
* The real sender runs here; the push library's signing, the dial and the
* database are stubbed.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PushSubscription } from "@/generated/prisma/client";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
findMany: vi.fn(),
deleteMany: vi.fn(),
getVapidConfig: vi.fn(),
safeFetch: vi.fn(),
generateRequestDetails: vi.fn(),
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T>(fn: T) => fn,
requireAuth: mocks.requireAuth,
}));
vi.mock("@/lib/db", () => ({
prisma: {
pushSubscription: {
findFirst: vi.fn(),
findMany: mocks.findMany,
deleteMany: mocks.deleteMany,
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => null,
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((x: string) => x),
}));
vi.mock("@/lib/crypto", () => ({ decrypt: (v: string) => v }));
vi.mock("@/lib/notifications/vapid-config", () => ({
getVapidConfig: vi.fn(async () => ({
subject: "mailto:test@example.com",
publicKey:
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
privateKey: "private-key-stub",
})),
getVapidConfig: mocks.getVapidConfig,
}));
const sendNotification = vi.fn();
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttemptForPayload: vi.fn(),
}));
vi.mock("@/lib/safe-fetch", () => ({ safeFetch: mocks.safeFetch }));
vi.mock("web-push", () => ({
default: {
setVapidDetails: vi.fn(),
sendNotification: (...args: unknown[]) => sendNotification(...args),
},
setVapidDetails: vi.fn(),
sendNotification: (...args: unknown[]) => sendNotification(...args),
generateRequestDetails: mocks.generateRequestDetails,
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
interface ApiErrorEnvelope {
data: null;
error: string;
}
interface ApiSuccessEnvelope<T> {
data: T;
error: null;
type Handler = (request: Request) => Promise<Response>;
const post = () =>
(POST as unknown as Handler)(
new Request("http://localhost/api/notifications/web-push/test", {
method: "POST",
}),
);
function subscription(id: string, endpoint: string): PushSubscription {
return {
id,
userId: "u-1",
endpoint,
p256dh: "p256",
auth: "auth",
userAgent: null,
createdAt: new Date(0),
};
}
const originalFetch = global.fetch;
const PHONE = subscription(
"s-phone",
"https://fcm.googleapis.com/fcm/send/phone-secret",
);
const LAPTOP = subscription(
"s-laptop",
"https://updates.push.services.mozilla.com/wpush/v2/laptop-secret",
);
/** The JSON payload the sender encrypted for each subscription. */
function payloadsSent(): Array<{ title: string; body: string; tag: string }> {
return mocks.generateRequestDetails.mock.calls.map(([, payload]) =>
JSON.parse(String(payload)),
);
}
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.pushSubscription.findFirst).mockReset();
sendNotification.mockReset();
global.fetch = vi.fn() as never;
});
afterEach(() => {
global.fetch = originalFetch;
});
function emptyRequest(): Request {
return new Request("http://localhost/api/notifications/web-push/test", {
method: "POST",
headers: { "content-length": "0" },
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue({ user: { id: "u-1", locale: "de" } });
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
mocks.getVapidConfig.mockResolvedValue({
subject: "mailto:ops@example.com",
publicKey: "pub",
privateKey: "priv",
});
}
mocks.findMany.mockResolvedValue([PHONE, LAPTOP]);
mocks.deleteMany.mockResolvedValue({ count: 0 });
mocks.generateRequestDetails.mockImplementation(
(sub: { endpoint: string }) => ({
endpoint: sub.endpoint,
method: "POST",
headers: { TTL: "60" },
body: null,
}),
);
mocks.safeFetch.mockResolvedValue(new Response(null, { status: 201 }));
});
describe("POST /api/notifications/web-push/test", () => {
it("happy path returns ok and sends to one subscription", async () => {
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({
id: "p-1",
userId: "u-1",
endpoint: "https://fcm.googleapis.com/fcm/send/abc123token",
p256dh: "p256",
auth: "auth",
} as never);
sendNotification.mockResolvedValueOnce({ statusCode: 201 });
it("sends to every subscribed browser, in the account's language", async () => {
const res = await post();
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
const body = (await response.json()) as ApiSuccessEnvelope<{
ok: boolean;
sent: number;
latencyMs: number;
perEndpoint: Array<{ host: string; status: number | null }>;
}>;
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { ok: boolean; latencyMs: number };
};
expect(body.data.ok).toBe(true);
expect(body.data.sent).toBe(1);
expect(body.data.perEndpoint).toHaveLength(1);
expect(body.data.perEndpoint[0].host).toBe("fcm.googleapis.com");
// never the full endpoint URL (the routing token is the only thing
// protecting that subscription from anyone who learns it).
const text = JSON.stringify(body);
expect(text).not.toMatch(/abc123token/);
expect(typeof body.data.latencyMs).toBe("number");
// Spec: only the most-recent subscription gets the test push, not all.
expect(prisma.pushSubscription.findFirst).toHaveBeenCalledWith(
expect(mocks.findMany).toHaveBeenCalledWith({ where: { userId: "u-1" } });
expect(mocks.safeFetch.mock.calls.map(([url]) => url)).toEqual([
PHONE.endpoint,
LAPTOP.endpoint,
]);
for (const call of mocks.safeFetch.mock.calls) {
expect(call[2]).toEqual({ requirePublicHost: true });
}
expect(payloadsSent()).toEqual([
expect.objectContaining({
where: { userId: "u-1" },
orderBy: { createdAt: "desc" },
title: "Test-Benachrichtigung von HealthLog",
body: "Wenn du das siehst, kommen Push-Benachrichtigungen auf diesem Gerät an.",
tag: "self-test",
}),
);
expect(sendNotification).toHaveBeenCalledTimes(1);
expect.objectContaining({ tag: "self-test" }),
]);
});
it("rate-limit denial returns 429 with no upstream call", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
expect(sendNotification).not.toHaveBeenCalled();
it("answers 422 not_configured when no browser is subscribed", async () => {
mocks.findMany.mockResolvedValue([]);
const res = await post();
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("not_configured");
expect(mocks.safeFetch).not.toHaveBeenCalled();
});
it("returns 422 when no subscriptions registered", async () => {
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce(null);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.error).toMatch(/no push subscriptions/i);
expect(sendNotification).not.toHaveBeenCalled();
it("answers 422 vapid_not_configured without VAPID keys", async () => {
mocks.getVapidConfig.mockResolvedValue(null);
const res = await post();
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("vapid_not_configured");
expect(mocks.safeFetch).not.toHaveBeenCalled();
});
it("does not leak Bearer/sk- or full endpoint URL when upstream rejects", async () => {
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({
id: "p-1",
userId: "u-1",
endpoint: "https://fcm.googleapis.com/fcm/send/abc123token",
p256dh: "p256",
auth: "auth",
} as never);
sendNotification.mockRejectedValueOnce(
Object.assign(
new Error(
"401 Unauthorized: Authorization: Bearer sk-secret from https://fcm.googleapis.com/fcm/send/abc123token",
),
{ statusCode: 401 },
),
);
const response = await POST(emptyRequest() as never);
const text = await response.text();
expect(response.status).toBe(200);
expect(text).not.toMatch(/sk-/);
expect(text).not.toMatch(/abc123token/);
const parsed = JSON.parse(text) as ApiSuccessEnvelope<{
ok: boolean;
sent: number;
perEndpoint: Array<{ host: string; status: number | null }>;
}>;
expect(parsed.data.ok).toBe(false);
expect(parsed.data.sent).toBe(0);
expect(parsed.data.perEndpoint[0].host).toBe("fcm.googleapis.com");
expect(parsed.data.perEndpoint[0].status).toBe(401);
it("refuses over the rate limit without sending", async () => {
mocks.checkRateLimit.mockResolvedValue({ allowed: false });
const res = await post();
expect(res.status).toBe(429);
expect(mocks.safeFetch).not.toHaveBeenCalled();
});
it("reports a rejected push without echoing an endpoint", async () => {
mocks.safeFetch.mockResolvedValue(new Response(null, { status: 401 }));
const res = await post();
const text = await res.text();
expect(res.status).toBe(200);
expect(JSON.parse(text).data.ok).toBe(false);
// What the Test button reads to say why.
expect(JSON.parse(text).meta.errorCode).toBe("upstream_rejected");
expect(text).not.toMatch(/phone-secret|laptop-secret/);
});
it("tells a push service that never answered apart from one that refused", async () => {
mocks.safeFetch.mockRejectedValue(new TypeError("fetch failed"));
const res = await post();
const body = (await res.json()) as {
data: { ok: boolean };
meta?: { errorCode?: string };
};
expect(body.data.ok).toBe(false);
expect(body.meta?.errorCode).toBe("connection_failed");
});
});
@@ -1,30 +1,22 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { apiSuccess, apiSuccessWithMeta, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
import { coerceLocale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
export const dynamic = "force-dynamic";
function hostFromEndpoint(endpoint: string): string {
try {
return new URL(endpoint).host;
} catch {
return "unknown";
}
}
// Strip any URL from a stringified web-push library error. Push providers (FCM,
// Mozilla autopush, etc.) embed the full subscription endpoint into 410/404
// error messages — that endpoint token is the routing secret, so it must never
// land in our Wide Events.
function redactPushError(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[endpoint]");
}
/**
* POST /api/notifications/web-push/test
*
* Sends a test push to every browser subscribed for the account, through the
* sender real reminders use, in the account's language. Pressing Test on one
* device must reach that device; the newest subscription alone did not when
* a person had more than one.
*/
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
@@ -37,68 +29,40 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
// Spec: only the most-recent subscription receives the test push. A user
// with phone + tablet + desktop should not get three buzzes per click.
const subscription = await prisma.pushSubscription.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
const { t } = getServerTranslator(coerceLocale(user.locale));
const start = performance.now();
const result = await sendViaWebPush(user.id, {
eventType: "SYSTEM_ALERT",
userId: user.id,
title: t("notifications.user.webPushTestTitle"),
message: t("notifications.user.webPushTestBody"),
metadata: { webPushTag: "self-test" },
});
const latencyMs = Math.round(performance.now() - start);
if (!subscription) {
if (result.reason === "web_push_no_subscriptions") {
return apiError("No push subscriptions registered", 422, {
errorCode: "not_configured",
});
}
const config = await getVapidConfig();
if (!config) {
if (result.reason === "web_push_vapid_not_configured") {
return apiError("VAPID keys not configured", 422, {
errorCode: "vapid_not_configured",
});
}
const webpush = await import("web-push");
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
const pushPayload = JSON.stringify({
title: "HealthLog",
body: "This is a test notification from HealthLog",
tag: "self-test",
annotate({
meta: { web_push_test_ok: result.ok, web_push_test_reason: result.reason },
});
const host = hostFromEndpoint(subscription.endpoint);
const start = performance.now();
try {
const p256dh = decrypt(subscription.p256dh);
const auth = decrypt(subscription.auth);
const result = await webpush.sendNotification(
{ endpoint: subscription.endpoint, keys: { p256dh, auth } },
pushPayload,
);
const latencyMs = Math.round(performance.now() - start);
return apiSuccess({
ok: true,
sent: 1,
latencyMs,
perEndpoint: [{ host, status: result.statusCode ?? 201 }],
});
} catch (err: unknown) {
const status = (err as { statusCode?: number }).statusCode ?? null;
const message = redactPushError((err as Error).message ?? "").slice(0, 200);
annotate({
meta: {
web_push_test_status: status,
web_push_test_host: host,
web_push_test_error: message,
},
});
return apiSuccess({
ok: false,
sent: 0,
perEndpoint: [{ host, status }],
});
}
if (result.ok) return apiSuccess({ ok: true, latencyMs });
// The Test button says what went wrong from `meta.errorCode`, like every
// other channel's test; `reason` is the sender's own code.
return apiSuccessWithMeta(
{ ok: false, reason: result.reason },
{
errorCode: result.reason?.endsWith("_network_error")
? "connection_failed"
: "upstream_rejected",
},
);
});
@@ -0,0 +1,162 @@
/**
* The sign-in page, driven by its buttons.
*
* Single sign-on leaves the page for the identity provider and the session
* starts on the way back, so the previous account's offline copies have to
* be gone before the page is left. A sign-in that stays in the app replaces
* the sign-in page in the history, so Back does not show the form again.
* A refusal is said in the reader's language, never in the server's words.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import {
stubFetch,
type FetchStub,
} from "../../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../../tests/browser/render";
import { recordHistory } from "../../../../../tests/browser/url";
const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
const loadDocument = vi.hoisted(() => vi.fn());
vi.mock("@/lib/pwa/query-persister", () => ({
clearOfflineCachesForSessionEnd: clearOffline,
}));
vi.mock("@/lib/navigation/load-document", () => ({ loadDocument }));
import { MfaLoginStep } from "@/components/auth/mfa-login-step";
import LoginPage from "../page";
let api: FetchStub;
beforeEach(() => {
api = stubFetch()
.on("GET", "/api/auth/registration-status", {
data: { registrationEnabled: false, sessionCookieSecure: false },
})
.on("GET", "/api/auth/oidc/status", {
data: { enabled: true, buttonLabel: "Example ID", only: false },
});
});
afterEach(() => {
clearOffline.mockClear();
loadDocument.mockClear();
});
describe("sign-in page, single sign-on", () => {
it("clears the offline copies before leaving for the identity provider", async () => {
const { screen } = await renderWithApp(<LoginPage />, { user: null });
await screen.getByRole("button", { name: "Example ID" }).click();
await expect.poll(() => loadDocument.mock.calls.length).toBe(1);
expect(loadDocument.mock.calls[0][0]).toMatch(
/^\/api\/auth\/oidc\/login\?/,
);
expect(clearOffline).toHaveBeenCalledTimes(1);
expect(clearOffline.mock.invocationCallOrder[0]).toBeLessThan(
loadDocument.mock.invocationCallOrder[0],
);
});
});
describe("sign-in page, single sign-on with a stuck cache", () => {
it("still leaves for the identity provider when the wipe never settles", async () => {
// WebKit can leave an IndexedDB open hanging after the app returns from
// the background; the button did nothing then.
clearOffline.mockImplementationOnce(() => new Promise(() => {}));
const { screen } = await renderWithApp(<LoginPage />, { user: null });
await screen.getByRole("button", { name: "Example ID" }).click();
await expect
.poll(() => loadDocument.mock.calls.length, { timeout: 4000 })
.toBe(1);
expect(clearOffline).toHaveBeenCalledTimes(1);
});
});
describe("sign-in page, password", () => {
it("replaces itself with the page asked for", async () => {
api.on("POST", "/api/auth/login", { data: { id: "signed-in" } });
window.history.replaceState(null, "", "/auth/login?next=%2Fmood%3Fadd%3D1");
const history = recordHistory();
const { screen } = await renderWithApp(<LoginPage />, { user: null });
await screen.getByTestId("login-use-password").click();
await screen.getByTestId("login-email").fill("someone");
await page
.elementLocator(document.querySelector("#password")!)
.fill("a long enough passphrase");
await screen.getByTestId("login-password-submit").click();
await expect.poll(() => window.location.pathname).toBe("/mood");
expect(window.location.search).toBe("?add=1");
expect(history.pushes()).toEqual([]);
});
});
describe("sign-in refusals", () => {
async function signInWithPassword() {
const { screen } = await renderWithApp(<LoginPage />, { user: null });
await screen.getByTestId("login-use-password").click();
await screen.getByTestId("login-email").fill("someone");
await page
.elementLocator(document.querySelector("#password")!)
.fill("a long enough passphrase");
await screen.getByTestId("login-password-submit").click();
return screen;
}
it("says the sign-in failed instead of the server's words", async () => {
api.on("POST", "/api/auth/login", {
status: 401,
error: "Invalid credentials",
meta: { errorCode: "http.unauthenticated" },
});
const screen = await signInWithPassword();
await expect.element(screen.getByText("Login failed")).toBeVisible();
expect(document.body.textContent).not.toContain("Invalid credentials");
});
it("says to wait when the limit is reached", async () => {
api.on("POST", "/api/auth/login", {
status: 429,
error: "Too many login attempts. Please try again later.",
meta: { errorCode: "http.rate_limited" },
});
const screen = await signInWithPassword();
await expect
.element(
screen.getByText(
"Too many attempts. Wait a few minutes and try again.",
),
)
.toBeVisible();
});
it("does the same for the second factor", async () => {
api.on("POST", "/api/auth/mfa/verify", {
status: 401,
error: "Invalid code",
meta: { errorCode: "http.unauthenticated" },
});
const { screen } = await renderWithApp(
<MfaLoginStep
mfaTicket="ticket"
methods={["totp"]}
onSuccess={() => {}}
onCancel={() => {}}
/>,
{ user: null },
);
await screen.getByTestId("mfa-code-input").fill("123456");
await screen.getByTestId("mfa-code-submit").click();
await expect
.element(screen.getByText("Verification failed. Please try again."))
.toBeVisible();
expect(document.body.textContent).not.toContain("Invalid code");
});
});
+14 -20
View File
@@ -23,7 +23,9 @@ import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step";
import { OIDC_MFA_COOKIE, OIDC_MFA_COOKIE_PATH } from "@/lib/auth/oidc-cookie";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import { clearOfflineCachesBeforeLeaving } from "@/lib/pwa/clear-before-leaving";
import { sanitizeSameOriginPath } from "@/lib/url-safety";
import { localizedSignInError } from "@/lib/api/localized-error";
import { formatDate } from "@/lib/format";
import {
InsecureTransportBanner,
@@ -175,12 +177,14 @@ export default function LoginPage() {
staleTime: 60 * 1000,
});
function handleOidcLogin() {
async function handleOidcLogin() {
const params = new URLSearchParams({ next: getRedirectTarget() });
if (clientTransport) {
params.set("client_proto", clientTransport.protocol);
params.set("client_host", clientTransport.host);
}
// The session starts on the way back; the last account's copies go first.
await clearOfflineCachesBeforeLeaving();
loadDocument(`/api/auth/oidc/login?${params}`);
}
@@ -225,19 +229,13 @@ export default function LoginPage() {
// stale `["auth","me"]`, so the shell refetches it fresh on mount and the
// former explicit `invalidateQueries({ queryKey: auth() })` is redundant.
queryClient.clear();
// Belt-and-suspenders to match the session-END wipe (logout / 401-expiry):
// also drop the OFFLINE layers — the IndexedDB query snapshot and the SW
// data/page caches — so a previous account's persisted dashboard never
// lingers on disk for the new session. The restore is already per-account
// bound, but a fresh login on a shared browser should leave nothing of the
// prior account behind. Order is clear → prefetch → navigate: the wipe is
// started here (best-effort, like logout) before the prefetch warms THIS
// user's data, and the persister's 1 s debounce gives the single IDB delete
// ample headroom to land before the new snapshot is written.
// The offline layers too, as at every session end. Started before the
// prefetch; the persister's 1 s debounce lets the delete land before the
// new account's snapshot is written.
void clearOfflineCachesForSessionEnd();
const target = getRedirectTarget();
if (target === "/") prefetchDashboardSnapshot(queryClient);
router.push(target);
router.replace(target);
}
async function handlePasskeyLogin() {
@@ -263,10 +261,10 @@ export default function LoginPage() {
navigateAfterLogin();
} catch (err) {
if (noteRefusal(err)) return;
// Route rejections carry the envelope error verbatim; everything
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
// A route's refusal is named by its code; a WebAuthn ceremony failure
// keeps the descriptive mapping.
if (err instanceof ApiError) {
setError(err.message || t("auth.loginFailed"));
setError(localizedSignInError(err, t, "auth.loginFailed"));
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
@@ -310,11 +308,7 @@ export default function LoginPage() {
navigateAfterLogin();
} catch (err) {
if (noteRefusal(err)) return;
setError(
err instanceof ApiError && err.message
? err.message
: t("auth.loginFailed"),
);
setError(localizedSignInError(err, t, "auth.loginFailed"));
} finally {
setLoading(false);
}
@@ -461,7 +455,7 @@ export default function LoginPage() {
<InsecureTransportBanner verdict={verdict} />
{oidcStatus?.enabled && (
<Button
onClick={handleOidcLogin}
onClick={() => void handleOidcLogin()}
variant={oidcStatus.only ? "default" : "outline"}
className="min-h-11 w-full"
size="lg"
@@ -0,0 +1,173 @@
/**
* The registration page, driven by its form.
*
* A new account starts from an empty cache, like every other sign-in: the
* client cache outlives the navigation, and nothing read before the account
* existed belongs to it. The app replaces the page in the history, so Back
* does not show the form again.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { queryKeys } from "@/lib/query-keys";
import { stubFetch } from "../../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../../tests/browser/render";
import { recordHistory } from "../../../../../tests/browser/url";
import RegisterPage from "../page";
async function fill(selector: string, value: string): Promise<void> {
await page.elementLocator(document.querySelector(selector)!).fill(value);
}
describe("registration page", () => {
it("starts the new account from an empty cache", async () => {
const api = stubFetch()
.on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
.on("POST", "/api/auth/register", { data: { id: "new-account" } });
window.history.replaceState(null, "", "/auth/register");
const history = recordHistory();
const { screen, queryClient } = await renderWithApp(<RegisterPage />, {
user: null,
});
// Left over from whoever used this browser before.
queryClient.setQueryData(queryKeys.dashboardSnapshot(), { stale: true });
await fill("#email", "new@example.com");
await fill("#username", "newcomer");
await fill("#password", "a long enough passphrase");
await screen.getByRole("button", { name: "Sign up" }).click();
await expect.poll(() => window.location.pathname).toBe("/");
expect(api.calls("POST", "/api/auth/register")).toHaveLength(1);
expect(history.pushes()).toEqual([]);
expect(queryClient.getQueryData(queryKeys.dashboardSnapshot())).toBe(
undefined,
);
});
});
describe("registration page, already signed in", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("takes this browser's push subscription with it on sign-out", async () => {
// The button exists so someone else can register on this browser; a
// subscription left behind would show them the last account's reminders.
const unsubscribe = vi.fn(async () => true);
vi.spyOn(navigator.serviceWorker, "getRegistration").mockResolvedValue({
pushManager: {
getSubscription: async () => ({
endpoint: "https://push.example.test/send/abc",
unsubscribe,
}),
},
} as unknown as ServiceWorkerRegistration);
const api = stubFetch()
.on("DELETE", "/api/notifications/web-push", { data: {} })
.on("POST", "/api/auth/logout", { data: { loggedOut: true } });
window.history.replaceState(null, "", "/auth/register?invite=hlv_x");
const { screen } = await renderWithApp(<RegisterPage />, {
user: { username: "signed-in" },
});
await screen.getByTestId("register-sign-out").click();
await expect.poll(() => unsubscribe.mock.calls.length).toBe(1);
const order = api.requests
.map((r) => `${r.method} ${r.path.split("?")[0]}`)
.filter((call) => !call.startsWith("GET "));
expect(order).toEqual([
"DELETE /api/notifications/web-push",
"POST /api/auth/logout",
]);
expect(api.calls("DELETE", "/api/notifications/web-push")[0].body).toEqual({
endpoint: "https://push.example.test/send/abc",
});
});
it("keeps the page and says so when the server refuses the sign-out", async () => {
const api = stubFetch().on("POST", "/api/auth/logout", {
status: 403,
error: "Demo mode: modifications are disabled",
meta: { demo: true, errorCode: "demo.readOnly" },
});
window.history.replaceState(null, "", "/auth/register?invite=hlv_x");
const { screen, queryClient } = await renderWithApp(<RegisterPage />, {
user: { username: "signed-in" },
});
await screen.getByTestId("register-sign-out").click();
await expect
.element(screen.getByText("Could not sign out. You are still signed in."))
.toBeVisible();
expect(api.calls("POST", "/api/auth/logout")).toHaveLength(1);
// Nothing was wiped and nobody was sent anywhere: the session is alive.
expect(queryClient.getQueryData(queryKeys.authMe())).toBeDefined();
expect(window.location.search).toBe("?invite=hlv_x");
});
});
describe("registration refusals", () => {
async function register(
response: Parameters<ReturnType<typeof stubFetch>["on"]>[2],
) {
stubFetch()
.on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
.on("POST", "/api/auth/register", response);
const { screen } = await renderWithApp(<RegisterPage />, { user: null });
await fill("#email", "new@example.com");
await fill("#username", "newcomer");
await fill("#password", "a long enough passphrase");
await screen.getByRole("button", { name: "Sign up" }).click();
return screen;
}
it("says what to change when the name or address is taken", async () => {
const screen = await register({
status: 409,
error: "Username or email already taken",
meta: { errorCode: "auth.register.taken" },
});
await expect
.element(
screen.getByText("That username or email address is already in use."),
)
.toBeVisible();
expect(document.body.textContent).not.toContain("already taken");
});
it("tells an invitee their link no longer works", async () => {
const screen = await register({
status: 403,
error: "Invalid or expired invite",
meta: { errorCode: "auth.register.invite_invalid" },
});
await expect
.element(screen.getByText(/This invitation is no longer valid/))
.toBeVisible();
});
it("says the registration failed for a refusal it has no words for", async () => {
const screen = await register({
status: 500,
error: "Internal error",
meta: { errorCode: "http.internal" },
});
await expect.element(screen.getByText("Registration failed")).toBeVisible();
expect(document.body.textContent).not.toContain("Internal error");
});
it("shows the password feedback, which the server words for the reader", async () => {
const screen = await register({
status: 422,
error: "Füge ein oder zwei Wörter hinzu.",
meta: { errorCode: "http.invalid" },
});
await expect
.element(screen.getByText("Füge ein oder zwei Wörter hinzu."))
.toBeVisible();
});
});
+16 -14
View File
@@ -12,9 +12,13 @@ import { Label } from "@/components/ui/label";
import { PasswordStrength } from "@/components/ui/password-strength";
import { useTranslations } from "@/lib/i18n/context";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
clearCachesForSessionEnd,
signOutRequest,
useAuth,
} from "@/hooks/use-auth";
export default function RegisterPage() {
const router = useRouter();
@@ -60,11 +64,8 @@ export default function RegisterPage() {
setLoading(true);
try {
// v1.4.25 — ship the browser-detected timezone with the
// signup payload. Server validates against the IANA list and
// falls back to the admin server-default if the value is
// bogus, so a tampered or proxied request can never poison
// the user's stored zone.
// The server checks the zone against the IANA list and falls back to
// its default, so a tampered value never reaches the account.
const timezone = detectBrowserTimezone();
await apiPost("/api/auth/register", {
email,
@@ -74,13 +75,14 @@ export default function RegisterPage() {
...(inviteToken ? { inviteToken } : {}),
});
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/");
queryClient.clear(); // nothing cached before belongs to the new account
router.replace("/");
} catch (err) {
// A 422 says what to fix (the password feedback comes localized).
setError(
err instanceof ApiError && err.message
err instanceof ApiError && err.status === 422 && err.message
? err.message
: t("auth.registerFailed"),
: localizedApiError(err, t, "auth.registerFailed"),
);
} finally {
setLoading(false);
@@ -91,7 +93,7 @@ export default function RegisterPage() {
setError(null);
setSigningOut(true);
try {
await apiFetchRaw("/api/auth/logout", { method: "POST" });
await signOutRequest();
// Same session-end wipe the shell and the menu do: the QueryClient
// outlives the navigation on this SPA and the health-data families are
// not user-scoped, so nothing may survive into the next account.
@@ -106,7 +108,7 @@ export default function RegisterPage() {
);
router.refresh();
} catch {
setError(t("common.networkError"));
setError(t("auth.signOutFailed"));
} finally {
setSigningOut(false);
}
+10
View File
@@ -724,6 +724,16 @@ body,
}
}
/*
* The installed iOS app runs with `black-translucent` (`layout.tsx`), which
* always draws the clock and battery in white. Over the light theme's card
* colour they disappear, so the light theme paints the strip in its dark
* ink instead.
*/
:root.light .shell-safe-area {
background-image: linear-gradient(var(--foreground), var(--foreground));
}
/*
* The on-screen keyboard. iOS Safari, and Chrome on Android by default,
* shrink only the visual viewport when the keyboard opens: everything
@@ -0,0 +1,179 @@
/**
* Taking over a managed profile, driven by the claim page's buttons.
*
* Both ways in start a session for a different account than whatever this
* browser held before: the password claim stays in the single-page app,
* starts from an empty cache and replaces the claim page in the history; the
* single-sign-on claim leaves for the identity provider and clears the
* offline copies before it goes.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { queryKeys } from "@/lib/query-keys";
import {
stubFetch,
type FetchStub,
} from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { recordHistory } from "../../../../tests/browser/url";
const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
const loadDocument = vi.hoisted(() => vi.fn());
const showErrorToast = vi.hoisted(() => vi.fn());
vi.mock("@/lib/pwa/query-persister", () => ({
clearOfflineCachesForSessionEnd: clearOffline,
}));
vi.mock("@/lib/navigation/load-document", () => ({ loadDocument }));
vi.mock("@/lib/errors/error-toast", () => ({ showErrorToast }));
import { ClaimProfile } from "../claim-profile";
const TOKEN = `hlp_${"c".repeat(64)}`;
let api: FetchStub;
beforeEach(() => {
window.history.replaceState(null, "", "/auth/claim");
api = stubFetch()
.on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
.on("POST", "/api/auth/claim/preview", {
data: {
displayName: "Robin",
expiresAt: "2030-01-01T00:00:00.000Z",
guardians: [],
methods: ["password", "sso"],
ssoProviderName: "Example ID",
},
})
.on("POST", "/api/auth/claim", {
data: { userId: "claimed", username: "robin" },
})
.on("POST", "/api/auth/claim/sso", {
data: { redirectTo: "/api/auth/oidc/login?claim=1" },
});
});
afterEach(() => {
clearOffline.mockClear();
loadDocument.mockClear();
showErrorToast.mockClear();
});
async function fill(selector: string, value: string): Promise<void> {
await expect.poll(() => document.querySelector(selector)).toBeTruthy();
await page.elementLocator(document.querySelector(selector)!).fill(value);
}
async function click(slot: string): Promise<void> {
await page
.elementLocator(document.querySelector(`[data-slot="${slot}"]`)!)
.click();
}
describe("ClaimProfile", () => {
it("starts the claimed account from an empty cache", async () => {
const history = recordHistory();
const { queryClient } = await renderWithApp(
<ClaimProfile token={TOKEN} />,
{
user: null,
},
);
queryClient.setQueryData(queryKeys.dashboardSnapshot(), { stale: true });
await fill("#claim-username", "robin");
await fill("#claim-email", "robin@example.com");
await fill("#claim-password", "a long enough passphrase");
await click("claim-submit");
await expect
.poll(() => window.location.pathname)
.toBe("/onboarding/handover");
expect(api.calls("POST", "/api/auth/claim")).toHaveLength(1);
expect(history.pushes()).toEqual([]);
expect(queryClient.getQueryData(queryKeys.dashboardSnapshot())).toBe(
undefined,
);
});
it("clears the offline copies before leaving for the identity provider", async () => {
await renderWithApp(<ClaimProfile token={TOKEN} />, { user: null });
await fill("#claim-username", "robin");
await click("claim-sso-continue");
await expect.poll(() => loadDocument.mock.calls.length).toBe(1);
expect(loadDocument.mock.calls[0][0]).toMatch(
/^\/api\/auth\/oidc\/login\?claim=1/,
);
expect(clearOffline).toHaveBeenCalledTimes(1);
expect(clearOffline.mock.invocationCallOrder[0]).toBeLessThan(
loadDocument.mock.invocationCallOrder[0],
);
});
});
describe("ClaimProfile, already signed in", () => {
it("takes this browser's push subscription with it on sign-out", async () => {
// The button exists so the profile's person can take over on this
// browser; a subscription left behind would show them the last
// account's reminders.
const unsubscribe = vi.fn(async () => true);
const registration = vi
.spyOn(navigator.serviceWorker, "getRegistration")
.mockResolvedValue({
pushManager: {
getSubscription: async () => ({
endpoint: "https://push.example.test/send/abc",
unsubscribe,
}),
},
} as unknown as ServiceWorkerRegistration);
api
.on("DELETE", "/api/notifications/web-push", { data: {} })
.on("POST", "/api/auth/logout", { data: { loggedOut: true } });
await renderWithApp(<ClaimProfile token={TOKEN} />, {
user: { username: "signed-in" },
});
await click("claim-sign-out");
await expect.poll(() => unsubscribe.mock.calls.length).toBe(1);
const order = api.requests
.map((r) => `${r.method} ${r.path.split("?")[0]}`)
.filter((call) => !call.startsWith("GET "));
// The signed-out page then loads the link's preview.
expect(order.slice(0, 2)).toEqual([
"DELETE /api/notifications/web-push",
"POST /api/auth/logout",
]);
registration.mockRestore();
});
it("keeps the session's page and says so when the server refuses the sign-out", async () => {
api.on("POST", "/api/auth/logout", {
status: 403,
error: "Demo mode: modifications are disabled",
meta: { demo: true, errorCode: "demo.readOnly" },
});
const { queryClient } = await renderWithApp(
<ClaimProfile token={TOKEN} />,
{
user: { username: "signed-in" },
},
);
await click("claim-sign-out");
await expect.poll(() => showErrorToast.mock.calls.length).toBe(1);
expect(showErrorToast.mock.calls[0][0]).toBe(
"Could not sign out. You are still signed in.",
);
expect(clearOffline).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.authMe())).toBeDefined();
expect(window.location.search).toBe("");
});
});
+20 -15
View File
@@ -13,16 +13,21 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/ui/logo";
import { PasswordStrength } from "@/components/ui/password-strength";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import {
clearCachesForSessionEnd,
signOutRequest,
useAuth,
} from "@/hooks/use-auth";
import { ApiError } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { loadDocument } from "@/lib/navigation/load-document";
import { clearOfflineCachesBeforeLeaving } from "@/lib/pwa/clear-before-leaving";
import {
useClaimProfile,
useClaimProfileSso,
useProfileClaimPreview,
} from "@/lib/queries/use-profile-claim";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.42 (#959) — the person a managed profile describes takes it over as
@@ -78,7 +83,7 @@ export function ClaimProfile({
async function signOut() {
setSigningOut(true);
try {
await apiFetchRaw("/api/auth/logout", { method: "POST" });
await signOutRequest();
clearCachesForSessionEnd(queryClient);
// Back to this page with the link intact: the token only rides the URL.
router.replace(
@@ -87,6 +92,8 @@ export function ClaimProfile({
: "/auth/claim",
);
router.refresh();
} catch (failure) {
showErrorToast(t("auth.signOutFailed"), { cause: failure });
} finally {
setSigningOut(false);
}
@@ -97,14 +104,15 @@ export function ClaimProfile({
claimSso.mutate(
{ token, username },
{
onSuccess: ({ redirectTo }) => {
// Only the path and query of the server's answer, so a response
// could never send the browser off this origin.
onSuccess: async ({ redirectTo }) => {
// Path and query only, so an answer can never leave this origin.
const next = new URL(redirectTo, window.location.origin);
if (clientTransport) {
next.searchParams.set("client_proto", clientTransport.protocol);
next.searchParams.set("client_host", clientTransport.host);
}
// The session starts on the way back; the last account's copies go first.
await clearOfflineCachesBeforeLeaving();
loadDocument(`${next.pathname}${next.search}`);
},
},
@@ -117,12 +125,9 @@ export function ClaimProfile({
claim.mutate(
{ token, username, email, password },
{
onSuccess: async () => {
queryClient.removeQueries({
queryKey: queryKeys.profileClaimPreview(),
});
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/handover");
onSuccess: () => {
queryClient.clear(); // as after any sign-in
router.replace("/onboarding/handover");
},
},
);
@@ -224,7 +229,7 @@ export function ClaimProfile({
title={t("auth.claim.title")}
description={t(
previewStatus === 429
? "auth.claim.errorRateLimit"
? "auth.signInRateLimited"
: "auth.claim.errorFailed",
)}
/>
@@ -543,6 +548,6 @@ export function claimErrorKey(err: unknown): string {
: "auth.claim.errorTaken";
}
if (err.status === 403) return "auth.claim.oidcOnly";
if (err.status === 429) return "auth.claim.errorRateLimit";
if (err.status === 429) return "auth.signInRateLimited";
return "auth.claim.errorFailed";
}
+3 -4
View File
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { localizedSignInError } from "@/lib/api/localized-error";
import { currentClientTransport } from "@/lib/auth/client-transport";
/** The server's refusal for a page whose sign-in cookie cannot stick. */
@@ -73,9 +74,7 @@ export function MfaLoginStep({
err instanceof ApiError &&
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
? t("auth.insecureTransport.title")
: err instanceof ApiError && err.message
? err.message
: t("auth.mfa.verifyFailed"),
: localizedSignInError(err, t, "auth.mfa.verifyFailed"),
);
} finally {
setLoading(false);
@@ -102,7 +101,7 @@ export function MfaLoginStep({
onSuccess();
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || t("auth.mfa.verifyFailed"));
setError(localizedSignInError(err, t, "auth.mfa.verifyFailed"));
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
@@ -0,0 +1,146 @@
/**
* The app shell's scroll position across navigation.
*
* `<main>` is the shell's one scroller and stays mounted across routes, and
* the browser restores only the document's offset on Back. So the shell
* keeps the offsets itself: a new page starts at the top, Back returns to
* where the list was left.
*/
import { useEffect, useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { usePathname } from "next/navigation";
import { URL_STATE_EVENT } from "@/lib/url-state";
import { renderWithApp } from "../../../../tests/browser/render";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
// Chrome that is not the subject.
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
vi.mock("../top-bar", () => ({ TopBar: () => null }));
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
AchievementUnlockNotifier: () => null,
}));
vi.mock("@/components/insights/layout-coach-fab", () => ({
LayoutCoachFab: () => null,
}));
vi.mock("@/components/insights/layout-coach-mount", () => ({
LayoutCoachMount: () => null,
}));
vi.mock("@/components/onboarding/tour-launcher", () => ({
TourLauncher: () => null,
}));
vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
vi.mock("@/components/command-palette/command-palette.lazy", () => ({
CommandPaletteMount: () => null,
}));
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
import { AuthShell } from "../auth-shell";
/**
* A long list on `/labs`, a short detail page anywhere else. The list is
* short while it loads (a skeleton), and each page mounts afresh.
*/
function Page() {
const pathname = usePathname();
return <PageBody key={pathname} pathname={pathname} />;
}
function PageBody({ pathname }: { pathname: string }) {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setLoaded(true), 200);
return () => clearTimeout(timer);
}, []);
const long = pathname === "/labs" && loaded;
return (
<div
data-slot="page"
data-path={pathname}
data-loaded={loaded || undefined}
style={{ height: long ? 4000 : 300 }}
/>
);
}
function main(): HTMLElement {
return document.getElementById("main-content")!;
}
async function nextFrames(): Promise<void> {
for (let i = 0; i < 3; i += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}
/** A link tap: the router pushes the entry and renders the next page. */
async function openPage(path: string): Promise<void> {
window.history.pushState(null, "", path);
window.dispatchEvent(new Event(URL_STATE_EVENT));
await expect
.poll(() => document.querySelector(`[data-path="${path}"]`))
.toBeTruthy();
}
describe("AuthShell scroll position", () => {
it("opens a new page at the top and returns to the list's offset on Back, once the list has loaded", async () => {
stubFetch();
window.history.replaceState(null, "", "/labs");
await renderWithApp(
<AuthShell>
<Page />
</AuthShell>,
{ user: { id: "signed-in" } },
);
await expect
.poll(() => document.querySelector("[data-loaded]"))
.toBeTruthy();
main().scrollTop = 1500;
await nextFrames();
expect(main().scrollTop).toBe(1500);
await openPage("/labs/biomarker-1");
await expect.poll(() => main().scrollTop).toBe(0);
window.history.back();
await expect
.poll(() => document.querySelector('[data-path="/labs"]'))
.toBeTruthy();
await expect.poll(() => main().scrollTop).toBe(1500);
});
it("stops pulling back once the person scrolls on a list that is still loading", async () => {
stubFetch();
window.history.replaceState(null, "", "/labs");
await renderWithApp(
<AuthShell>
<Page />
</AuthShell>,
{ user: { id: "signed-in" } },
);
await expect
.poll(() => document.querySelector("[data-loaded]"))
.toBeTruthy();
main().scrollTop = 1500;
await nextFrames();
await openPage("/labs/biomarker-1");
window.history.back();
await expect
.poll(() => document.querySelector('[data-path="/labs"]'))
.toBeTruthy();
// While the list is still its short skeleton, the person scrolls.
window.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
await expect
.poll(() => document.querySelector("[data-loaded]"))
.toBeTruthy();
await nextFrames();
expect(main().scrollTop).toBe(0);
});
});
@@ -0,0 +1,217 @@
/**
* What the shell does once the server says there is no session.
*
* On an `/auth/*` page it clears the previous account's offline copies, once,
* so a session that ended without a sign-out (an expired cookie the edge sent
* straight to the sign-in page) leaves nothing on the device for the next
* account. It never clears on an answer that is not a verdict.
*
* On a protected page it sends the visitor to the sign-in page, carrying the
* page along when the session ended on its own, and not after a deliberate
* sign-out: the next person on the device starts from a bare sign-in page.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { authUser, renderWithApp } from "../../../../tests/browser/render";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("@/lib/pwa/query-persister", () => ({
clearOfflineCachesForSessionEnd: clearOffline,
clearOfflineCachesForRecordSwitch: vi.fn(async () => {}),
}));
// Chrome that is not the subject.
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
vi.mock("../top-bar", () => ({ TopBar: () => null }));
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
AchievementUnlockNotifier: () => null,
}));
vi.mock("@/components/insights/layout-coach-fab", () => ({
LayoutCoachFab: () => null,
}));
vi.mock("@/components/insights/layout-coach-mount", () => ({
LayoutCoachMount: () => null,
}));
vi.mock("@/components/onboarding/tour-launcher", () => ({
TourLauncher: () => null,
}));
vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
vi.mock("@/components/command-palette/command-palette.lazy", () => ({
CommandPaletteMount: () => null,
}));
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
import { clearCachesForSessionEnd } from "@/hooks/use-auth";
import { AuthShell } from "../auth-shell";
const ME = "/api/auth/me";
function openAt(path: string): void {
window.history.replaceState(null, "", path);
}
/** The tree `renderWithApp` mounts, for rendering it again. */
function shellTree(queryClient: QueryClient) {
return (
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<AuthShell>
<div data-slot="page" />
</AuthShell>
</I18nProvider>
</QueryClientProvider>
);
}
/** HealthLog's answer once there is no session; it always names a code. */
const SIGNED_OUT = {
status: 401,
error: "Unauthorized",
meta: { errorCode: "auth.missing" },
};
async function mountShell(user: Parameters<typeof authUser>[0] | null) {
return renderWithApp(
<AuthShell>
<div data-slot="page" />
</AuthShell>,
{ user },
);
}
async function settle(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 150));
}
afterEach(() => {
clearOffline.mockClear();
});
describe("AuthShell on a sign-in page", () => {
it("clears the offline copies once the server says there is no session", async () => {
const fetchStub = stubFetch().on("GET", ME, SIGNED_OUT);
openAt("/auth/login");
const { queryClient } = await mountShell(null);
await expect.poll(() => clearOffline.mock.calls.length).toBe(1);
// A second answer of the same kind does not clear again.
await queryClient.refetchQueries();
await settle();
expect(fetchStub.calls("GET", ME).length).toBeGreaterThan(1);
expect(clearOffline).toHaveBeenCalledTimes(1);
});
it("does the same on the profile take-over page", async () => {
stubFetch().on("GET", ME, SIGNED_OUT);
openAt("/auth/claim");
await mountShell(null);
await expect.poll(() => clearOffline.mock.calls.length).toBe(1);
});
it("leaves them alone for a signed-in visitor", async () => {
stubFetch();
openAt("/auth/register");
await mountShell({ id: "signed-in" });
await settle();
expect(clearOffline).not.toHaveBeenCalled();
});
it("leaves them alone when the answer carries no verdict", async () => {
// An offline launch or a 5xx says nothing about the session; clearing on
// it is what used to destroy the offline copies of a signed-in device.
const fetchStub = stubFetch().on("GET", ME, {
status: 503,
error: "Unavailable",
});
openAt("/auth/login");
await mountShell(null);
// The account query retries a 5xx once; wait for both answers.
await expect
.poll(() => fetchStub.calls("GET", ME).length, { timeout: 4000 })
.toBe(2);
await settle();
expect(clearOffline).not.toHaveBeenCalled();
});
it("leaves them alone for a 401 from something in front of the server", async () => {
// A forward-auth proxy or a firewall answers with its own page, which
// names no HealthLog code and says nothing about this session.
const fetchStub = stubFetch().on("GET", ME, {
status: 401,
error: "Unauthorized",
});
openAt("/auth/login");
await mountShell(null);
await expect.poll(() => fetchStub.calls("GET", ME).length).toBe(1);
await settle();
expect(clearOffline).not.toHaveBeenCalled();
});
it("does not clear on a public page outside the sign-in flow", async () => {
stubFetch().on("GET", ME, SIGNED_OUT);
openAt("/about");
await mountShell(null);
await settle();
expect(clearOffline).not.toHaveBeenCalled();
});
});
describe("AuthShell on a protected page", () => {
/** Signed in on `path`, with the marker a confirmed session leaves. */
async function signedInAt(path: string) {
let session = true;
stubFetch().on("GET", ME, () =>
session ? { data: authUser({ id: "signed-in" }) } : SIGNED_OUT,
);
openAt(path);
const view = await mountShell(null);
await expect
.poll(() => document.querySelector('[data-slot="page"]'))
.toBeTruthy();
return {
...view,
endSession: () => {
session = false;
},
};
}
it("returns to the page after a session that ended on its own", async () => {
// Signed in once on this browser; the session has since expired while
// the cookie stayed, so the next visit gets past the edge and the
// account answer is the first to say so.
const first = await signedInAt("/");
first.endSession();
await first.screen.unmount();
openAt("/measurements?add=WEIGHT");
await mountShell(null);
await expect.poll(() => window.location.pathname).toBe("/auth/login");
expect(new URLSearchParams(window.location.search).get("next")).toBe(
"/measurements?add=WEIGHT",
);
});
it("does not carry the page past a deliberate sign-out", async () => {
const { screen, queryClient, endSession } =
await signedInAt("/medications");
// What the sign-out does once the server confirmed it; the navigation
// it starts renders the shell again, which asks for the account anew.
endSession();
clearCachesForSessionEnd(queryClient);
await screen.rerender(shellTree(queryClient));
await expect.poll(() => window.location.pathname).toBe("/auth/login");
expect(window.location.search).toBe("");
});
});
@@ -0,0 +1,46 @@
/**
* The strip behind the status bar on the sign-in pages.
*
* The installed iOS app draws the clock in white over whatever the page
* paints there. The signed-in shell paints the strip; the sign-in pages only
* padded it, so after a sign-out in the light theme the clock was white on
* near-white.
*/
import { afterEach, describe, expect, it } from "vitest";
import { renderWithApp } from "../../../../tests/browser/render";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
import { AuthShell } from "../auth-shell";
afterEach(() => {
document.documentElement.classList.remove("light");
document.documentElement.classList.add("dark");
});
describe("AuthShell status bar strip", () => {
it("is painted on the sign-in page in the light theme", async () => {
document.documentElement.classList.remove("dark");
document.documentElement.classList.add("light");
stubFetch().on("GET", "/api/auth/me", {
status: 401,
error: "Unauthorized",
meta: { errorCode: "auth.missing" },
});
window.history.replaceState(null, "", "/auth/login");
await renderWithApp(
<AuthShell>
<div data-slot="page" />
</AuthShell>,
{ user: null },
);
await expect
.poll(() => document.querySelector('[data-slot="page"]'))
.toBeTruthy();
const wrapper = document.getElementById("main-content")!.parentElement!;
expect(getComputedStyle(wrapper).backgroundImage).toContain(
"linear-gradient",
);
});
});
@@ -0,0 +1,70 @@
/**
* The "machine translated" strip is not part of signing in.
*
* It belongs to the app, where the language can be changed and a correction
* reported; on the sign-in, registration and take-over pages it was the first
* thing a visitor in eight of ten languages read, above the form.
*/
import { describe, expect, it, vi } from "vitest";
import { renderWithApp } from "../../../../tests/browser/render";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
// Chrome that is not the subject.
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
vi.mock("../top-bar", () => ({ TopBar: () => null }));
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
AchievementUnlockNotifier: () => null,
}));
vi.mock("@/components/insights/layout-coach-fab", () => ({
LayoutCoachFab: () => null,
}));
vi.mock("@/components/insights/layout-coach-mount", () => ({
LayoutCoachMount: () => null,
}));
vi.mock("@/components/onboarding/tour-launcher", () => ({
TourLauncher: () => null,
}));
vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
vi.mock("@/components/command-palette/command-palette.lazy", () => ({
CommandPaletteMount: () => null,
}));
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
import { AuthShell } from "../auth-shell";
const BANNER = '[data-slot="maintainership-banner"]';
async function mountAt(path: string, user: { id: string } | null) {
window.history.replaceState(null, "", path);
await renderWithApp(
<AuthShell>
<div data-slot="page" />
</AuthShell>,
{ user, locale: "fr" },
);
await expect
.poll(() => document.querySelector('[data-slot="page"]'))
.toBeTruthy();
}
describe("translation notice", () => {
it("is not shown on the sign-in page", async () => {
stubFetch().on("GET", "/api/auth/me", {
status: 401,
error: "Unauthorized",
});
await mountAt("/auth/login", null);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(document.querySelector(BANNER)).toBeNull();
});
it("is still shown inside the app", async () => {
stubFetch();
await mountAt("/", { id: "signed-in" });
await expect.poll(() => document.querySelector(BANNER)).toBeTruthy();
});
});
@@ -1,5 +1,5 @@
/**
* v1.4.43 — `<OfflineBanner>` SSR contract.
* `<OfflineBanner>` SSR contract and copy.
*
* The banner reads `navigator.onLine` + subscribes to `online`/`offline`
* window events inside a `useEffect`. SSR markup is therefore always
@@ -13,7 +13,6 @@ import { join } from "node:path";
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { OfflineBanner } from "../offline-banner";
@@ -22,20 +21,16 @@ const ROOT = join(__dirname, "../../../..");
const SHELL_PATH = join(ROOT, "src/components/layout/auth-shell.tsx");
const BANNER_PATH = join(ROOT, "src/components/layout/offline-banner.tsx");
function loadMessages(locale: string): { offlineBanner: { message: string } } {
function loadMessages(locale: string): {
offlineBanner: { message: string; stayOnPage: string };
} {
const path = join(ROOT, "messages", `${locale}.json`);
return JSON.parse(readFileSync(path, "utf8"));
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
// v1.18.6 — the banner now reads the cached dashboard snapshot to decide
// whether to append the "showing last synced data" hint, so it needs a
// QueryClient in context.
const queryClient = new QueryClient();
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
@@ -51,20 +46,31 @@ describe("<OfflineBanner>", () => {
it("ships the EN copy for `offlineBanner.message`", () => {
// The copy must not over-promise auto-save: offline mutations now
// fail fast (networkMode "always") rather than queue, so the banner states
// the honest thing.
// the honest thing. "Once you're back online" is said by the sentence
// after it, once.
const en = loadMessages("en");
expect(en.offlineBanner.message).toBe(
"No connection: changes can't be saved until you're back online.",
"No connection: changes can't be saved.",
);
});
it("ships the DE copy for `offlineBanner.message`", () => {
const de = loadMessages("de");
expect(de.offlineBanner.message).toBe(
"Keine Verbindung: Änderungen können erst gespeichert werden, wenn du wieder online bist.",
"Keine Verbindung: Änderungen können nicht gespeichert werden.",
);
});
it("says what still works: the open page, not the rest of the app", () => {
// The banner used to promise "your last synced data" although one tap
// on another page ended on the worker's offline page.
const en = loadMessages("en");
expect(en.offlineBanner.stayOnPage).toBe(
"This page stays readable; other pages open once you're back online.",
);
expect(en.offlineBanner).not.toHaveProperty("showingCached");
});
it("ships the message key in every supported locale", () => {
for (const locale of [
"en",
@@ -76,9 +82,11 @@ describe("<OfflineBanner>", () => {
"ko",
"nl",
"pt-BR",
"ru",
] as const) {
const m = loadMessages(locale);
expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0);
expect(m.offlineBanner.stayOnPage.length, locale).toBeGreaterThan(0);
}
});
@@ -89,12 +97,10 @@ describe("<OfflineBanner>", () => {
});
});
// v1.4.43 — also pin the client-side branch: when
// `isOnline === false` the banner paints with the i18n message,
// the WifiOff icon, and the `role="status"` aria-live region. We
// can't easily fire window events from a node-environment vitest
// suite, but we can read the component source and assert the
// branch carries the expected affordances.
// The client-side branch: when `isOnline === false` the banner paints with
// the i18n message, the WifiOff icon, and the `role="status"` aria-live
// region. Window events cannot fire in this node-environment suite, so the
// branch is read from the source.
describe("<OfflineBanner> rendered branch (source-level)", () => {
const source = readFileSync(BANNER_PATH, "utf8");
+124 -60
View File
@@ -10,11 +10,17 @@ import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import {
clearCachesForSessionEnd,
readWasAuthenticated,
useAuth,
} from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { signInHref } from "@/lib/api/read-failure";
import { useTranslations } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
// The leaf module, not the `@/lib/record-settings` barrel. This shell wraps
// every authenticated route, and the barrel also re-exports the managed
// settings-patch schemas — so one destination predicate that reads a slug
@@ -77,6 +83,99 @@ const PUBLIC_PATHS = [
"/claim/",
];
/**
* `<main id="main-content">` is the app shell's one scroller and stays mounted
* across routes, so its offset would carry from page to page, and the
* browser's own restoration on Back moves only the document, never `<main>`.
* A forward navigation starts at the top; Back and Forward return to the
* offset that entry was left at.
*/
function useShellScrollMemory(pathname: string): void {
// Recorded while scrolling: once the pathname has changed, `<main>` holds
// the next page and the offset is gone.
const offsets = useRef(new Map<string, number>());
// Set by `popstate` for the entry it lands on, taken by the next render.
const restore = useRef<{ url: string; top: number } | null>(null);
useEffect(() => {
const here = () => `${location.pathname}${location.search}`;
const remember = (event: Event) => {
const el = event.target;
if (el instanceof HTMLElement && el.id === "main-content") {
offsets.current.set(here(), el.scrollTop);
}
};
const recall = () => {
restore.current = { url: here(), top: offsets.current.get(here()) ?? 0 };
};
const capture = { capture: true, passive: true } as const;
document.addEventListener("scroll", remember, capture);
window.addEventListener("popstate", recall);
return () => {
document.removeEventListener("scroll", remember, capture);
window.removeEventListener("popstate", recall);
};
}, []);
useEffect(() => {
const target = restore.current;
restore.current = null;
const main = document.getElementById("main-content");
if (target?.url !== `${location.pathname}${location.search}`) {
if (main) main.scrollTop = 0;
// The body-scrolled shells (sign-in, setup, legal pages) scroll the
// document instead.
window.scrollTo(0, 0);
return;
}
if (!main) return;
// A list still painting its skeleton is shorter than the offset; keep
// trying until it has the height, for at most three seconds, and stop as
// soon as the person scrolls themselves.
const until = performance.now() + 3000;
let frame = 0;
const stop = () => cancelAnimationFrame(frame);
const inputs = ["wheel", "touchstart", "keydown", "pointerdown"];
const apply = () => {
main.scrollTop = target.top;
if (main.scrollTop < target.top - 1 && performance.now() < until) {
frame = requestAnimationFrame(apply);
}
};
for (const type of inputs) window.addEventListener(type, stop, true);
apply();
return () => {
stop();
for (const type of inputs) window.removeEventListener(type, stop, true);
};
}, [pathname]);
}
/**
* A session that ended without passing through the shell's session-end wipe
* (an expired cookie the edge sent straight to the sign-in page, a sign-in
* page opened directly) left the previous account's offline copies on this
* device. Clear them on every `/auth/*` page once the server has said there is
* no session, before anyone signs in. Once per signed-out stretch, and never
* on an answer that is not a verdict (offline, 5xx).
*/
function useSignInPageOfflineWipe(pathname: string): void {
const { isAuthenticated, isAuthUnknown, isLoading } = useAuth();
const cleared = useRef(false);
useEffect(() => {
if (isAuthenticated) {
cleared.current = false;
} else if (
!isLoading &&
!isAuthUnknown &&
pathname.startsWith("/auth/") &&
!cleared.current
) {
cleared.current = true;
void clearOfflineCachesForSessionEnd();
}
}, [isLoading, isAuthenticated, isAuthUnknown, pathname]);
}
export function AuthShell({
children,
demoMode = false,
@@ -181,63 +280,28 @@ export function AuthShell({
isBodyScrolled ? "body" : rendersAppShell ? "shell" : null,
);
// v1.25.1 — the authenticated shell keeps `<main id="main-content">`
// mounted across every route change, so its scroll offset survives
// navigation: scroll down in one section (e.g. Settings → Sources
// Priority), hop to another, and the new view opens already scrolled.
// Reset the scroll container to the top on every forward navigation.
// A back/forward navigation skips the reset so the browser's own scroll
// restoration stands: `popstate` arms a one-shot skip for the single
// pathname change it triggers, and a macrotask clears the flag so it can
// never get stuck on a later forward navigation.
const skipNextScrollReset = useRef(false);
useEffect(() => {
const armSkip = () => {
skipNextScrollReset.current = true;
setTimeout(() => {
skipNextScrollReset.current = false;
}, 0);
};
window.addEventListener("popstate", armSkip);
return () => window.removeEventListener("popstate", armSkip);
}, []);
useEffect(() => {
if (skipNextScrollReset.current) {
skipNextScrollReset.current = false;
return;
}
const main = document.getElementById("main-content");
if (main) {
main.scrollTop = 0;
}
// Fallback for the body-scrolled shells (login / onboarding / standalone
// legal pages) where the document, not `<main>`, owns the scroll.
window.scrollTo(0, 0);
}, [pathname]);
useShellScrollMemory(pathname);
// Session END on a protected page (`/api/auth/me` answered 401/403): wipe
// every client cache, since the QueryClient outlives navigation and health
// data is not user-scoped, then go to the sign-in page. Logout does the same
// from `useLogout`; this covers the expiry path it never runs through. No
// verdict (`isAuthUnknown`: offline, 5xx, no marker) never wipes: a wipe on
// a network failure is what used to destroy the offline caches.
//
// The target is fixed once per signed-out stretch, before the wipe clears
// the marker it reads (the refetch that follows runs this again). A session
// that ended on its own returns here after the next sign-in; a deliberate
// sign-out already cleared the marker, so the next person gets a bare page.
const signInTarget = useRef<string | null>(null);
useEffect(() => {
if (isAuthenticated || isPublicPage) signInTarget.current = null;
if (!isLoading && !isAuthenticated && !isPublicPage) {
// Session END (expired / invalidated cookie → `/api/auth/me` 401/403).
// Wipe the in-memory query cache (the cross-user guard — the
// QueryClient outlives the navigation on this SPA and the health-data
// families are not user-scoped) plus the persisted IndexedDB + SW
// caches, so nothing leaks to the next account on a shared browser.
// Logout does the same from `useLogout`; this covers the expiry path
// logout never runs through. Best-effort; the redirect proceeds
// regardless.
//
// `isAuthUnknown` = the auth probe failed WITHOUT a session verdict
// (offline relaunch, network blip, 5xx) and no prior-session marker
// exists. Nothing to protect and nothing to render — send the visitor
// to the login gate, but never run the wipe off a non-verdict: the
// wipe on a network failure is exactly what used to destroy the
// offline caches. (With the marker present, `isAuthenticated` holds
// the last-known state and this branch never runs.)
if (!isAuthUnknown) {
clearCachesForSessionEnd(queryClient);
}
router.replace("/auth/login");
signInTarget.current ??= readWasAuthenticated()
? signInHref()
: "/auth/login";
if (!isAuthUnknown) clearCachesForSessionEnd(queryClient);
router.replace(signInTarget.current);
}
}, [
isLoading,
@@ -247,6 +311,7 @@ export function AuthShell({
router,
queryClient,
]);
useSignInPageOfflineWipe(pathname);
// Redirect non-admins away from /admin
useEffect(() => {
@@ -279,12 +344,11 @@ export function AuthShell({
// `min-h-dvh`, not `h-dvh`: a phone held sideways is ~390 px tall, and
// a sign-in form that is taller than a fixed-height box centres out of
// it at the top, where nothing can scroll back to it. A minimum lets the
// box grow and the document scroll. The safe-area padding keeps the form
// off the status bar and the home indicator in an installed app.
// box grow and the document scroll. The safe-area band (painted behind
// the status bar, see globals.css) and padding keep the form clear.
return (
<div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top,0px)] pr-[env(safe-area-inset-right,0px)] pb-[env(safe-area-inset-bottom,0px)] pl-[env(safe-area-inset-left,0px)]">
<div className="shell-safe-area flex min-h-dvh flex-col pb-[env(safe-area-inset-bottom,0px)]">
{demoMode ? <DemoBanner /> : null}
<MaintainershipBanner />
<main
id="main-content"
className="flex flex-1 items-center justify-center px-4 py-6"
@@ -355,7 +419,7 @@ export function AuthShell({
{showUnlockNotifier && user?.id ? (
<AchievementUnlockNotifier userId={user.id} />
) : null}
<div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top,0px)] pr-[env(safe-area-inset-right,0px)] pl-[env(safe-area-inset-left,0px)]">
<div className="shell-safe-area flex min-h-dvh flex-col">
<MaintainershipBanner />
<div className="flex flex-1 items-center justify-center px-4 py-8">
{children}
+29 -43
View File
@@ -2,37 +2,28 @@
import { useEffect, useState } from "react";
import { WifiOff } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { isOfflineNavigationClick } from "@/lib/pwa/offline-navigation";
/**
* v1.4.43 — slim banner that surfaces "you're offline" while
* `navigator.onLine` reads false. The PWA service worker already
* serves the read paths from cache (`public/sw.js`), but pre-fix a
* user toggling airplane mode mid-form-fill saw blank tiles and no
* explanation. This banner closes the explanation gap.
* The strip that says the connection is down, and the guard that keeps the
* open page while it is.
*
* Mount strategy: rendered once at the top of `<AuthShell>` so every
* authenticated route inherits it. The component is mount-time SSR-
* safe — it never paints during the initial server render (the
* `online`/`offline` window events only fire client-side); a `useEffect`
* subscribes to the events once and toggles the visible state from
* there. Honours `prefers-reduced-motion` via the absence of any
* animation.
* Signed-in pages are never cached, so a navigation without a connection ends
* on the service worker's offline page and drops the page that was still
* readable. While the browser is offline, a tap on a link to another page of
* the app stays where it is and repeats the hint in a toast.
*
* Copy lives in `messages/*.json` under `offlineBanner.message`; all
* six locales ship today.
* Rendered once at the top of `<AuthShell>`. Hidden during SSR and the first
* client render (no hydration mismatch); the effect reads `navigator.onLine`
* after mount.
*/
export function OfflineBanner() {
const { t } = useTranslations();
const queryClient = useQueryClient();
// Initialise to `true` so the banner stays hidden during SSR + the
// first client render. The effect below flips it true/false after
// mount based on `navigator.onLine`. (We do NOT read navigator on
// the very first render to keep the SSR markup hydration-stable.)
const [isOnline, setIsOnline] = useState(true);
const stayOnPage = t("offlineBanner.stayOnPage");
useEffect(() => {
function handleOnline() {
@@ -41,17 +32,10 @@ export function OfflineBanner() {
function handleOffline() {
setIsOnline(false);
}
// Subscribe to the live transition events; both handlers route
// through the React setState pathway via the listener callback
// rather than a synchronous in-effect setState so the strict
// `react-hooks/set-state-in-effect` rule stays happy.
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
// Sync the initial state from `navigator.onLine` — deferred to a
// microtask so React sees this as an external-system bridge
// rather than a render-cascade setState. The legacy in-effect
// setState path tripped the strict lint rule, the queueMicrotask
// shape is the documented escape hatch.
// Deferred so the initial read is an external-system bridge, not a
// synchronous setState in the effect body.
queueMicrotask(() => {
if (typeof navigator !== "undefined" && navigator.onLine === false) {
setIsOnline(false);
@@ -63,18 +47,21 @@ export function OfflineBanner() {
};
}, []);
if (isOnline) return null;
useEffect(() => {
// Capture phase on `window`: runs before React's handlers on the
// document, so `next/link` sees the prevented default and stays put.
function keepOpenPage(event: MouseEvent) {
if (!isOfflineNavigationClick(event, window.location, navigator.onLine)) {
return;
}
event.preventDefault();
toast(stayOnPage, { id: "offline-navigation" });
}
window.addEventListener("click", keepOpenPage, true);
return () => window.removeEventListener("click", keepOpenPage, true);
}, [stayOnPage]);
// v1.18.6 — when an offline install is showing last-synced data (the SW's
// allowlisted read cache + the IndexedDB query persister hydrated the
// dashboard snapshot), say so honestly rather than implying a blank screen.
// v1.21.3 (b) — the snapshot cell is now locale-keyed, so match on the
// `["dashboard","snapshot"]` PREFIX (`getQueriesData`) rather than the
// exact zero-arg slot — any hydrated locale's snapshot proves cached data
// is present.
const hasCachedData = queryClient
.getQueriesData({ queryKey: queryKeys.dashboardSnapshot() })
.some(([, data]) => data != null);
if (isOnline) return null;
return (
<div
@@ -85,8 +72,7 @@ export function OfflineBanner() {
>
<WifiOff className="size-3.5 shrink-0" aria-hidden="true" />
<span className="text-center">
{t("offlineBanner.message")}
{hasCachedData ? ` ${t("offlineBanner.showingCached")}` : ""}
{t("offlineBanner.message")} {stayOnPage}
</span>
</div>
);
+8 -8
View File
@@ -277,18 +277,18 @@ function QueryVisibilityRefreshBridge() {
return null;
}
// ── Offline query persistence ────────────────────────
// ── Dashboard snapshot persistence ───────────────────
//
// v1.18.6 — hydrate the last-synced query cache from IndexedDB before the
// first authenticated paint, then debounce-persist successful reads back.
// Combined with the service worker's allowlisted stale-while-revalidate API
// branch, an installed PWA opened offline renders last-known data instead of
// empty skeletons. Build-version + age gated; cleared on logout.
// Fill the dashboard reads from the IndexedDB snapshot as a warm start, then
// debounce-persist them back. The restore waits for `/api/auth/me`: the
// snapshot belongs to one account, and only a known account can be compared
// with its owner. Build-version + age gated; cleared at every session end.
function QueryPersistenceBridge() {
const queryClient = useQueryClient();
const transition = useRecordSessionTransition();
const userId = useAuth().user?.id ?? null;
useEffect(() => {
if (transition.phase !== "ready") return;
if (transition.phase !== "ready" || userId === null) return;
let stop: (() => void) | undefined;
let cancelled = false;
void restorePersistedQueryCache(
@@ -304,7 +304,7 @@ function QueryPersistenceBridge() {
cancelled = true;
stop?.();
};
}, [queryClient, transition.phase]);
}, [queryClient, transition.phase, userId]);
return null;
}
@@ -101,6 +101,24 @@ describe("runConnectionTest", () => {
});
});
it("reads why a push test that ran was refused", async () => {
// The Web Push test answers 200 with `ok: false`; the code rides `meta`.
apiFetchRawMock.mockResolvedValue(
envelope(200, {
data: { ok: false, reason: "web_push_403" },
error: null,
meta: { errorCode: "upstream_rejected" },
}),
);
const result = await runConnectionTest("/api/notifications/web-push/test");
expect(result).toMatchObject({
kind: "error",
errorCode: "upstream_rejected",
});
});
it("reports success with the latency", async () => {
apiFetchRawMock.mockResolvedValue(
envelope(200, { data: { ok: true, latencyMs: 42 }, error: null }),
@@ -0,0 +1,62 @@
/**
* Which "push is unavailable" sentence the Web Push card shows.
*
* An iPhone in a Safari tab has no PushManager, the same as a browser that
* cannot do push at all, and the card used to tell both that push is not
* supported. On iOS the fix is to open HealthLog from the Home Screen, so
* that case gets its own sentence.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { needsHomeScreen } from "../web-push-card";
const IPHONE =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1";
const IPAD_AS_MAC =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15";
const ANDROID_FIREFOX =
"Mozilla/5.0 (Android 14; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0";
function device(
userAgent: string,
maxTouchPoints: number,
standalone: boolean,
) {
vi.stubGlobal("navigator", { userAgent, maxTouchPoints });
vi.stubGlobal("window", {
matchMedia: (query: string) => ({
matches: standalone && query === "(display-mode: standalone)",
}),
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("needsHomeScreen", () => {
it("asks an iPhone in a Safari tab to open the Home Screen app", () => {
device(IPHONE, 5, false);
expect(needsHomeScreen()).toBe(true);
});
it("asks an iPad, which reports itself as a Mac, the same", () => {
device(IPAD_AS_MAC, 5, false);
expect(needsHomeScreen()).toBe(true);
});
it("does not ask the installed app, where push is missing for real", () => {
device(IPHONE, 5, true);
expect(needsHomeScreen()).toBe(false);
});
it("does not ask a Mac without a touch screen", () => {
device(IPAD_AS_MAC, 0, false);
expect(needsHomeScreen()).toBe(false);
});
it("does not ask a browser that has no push at all", () => {
device(ANDROID_FIREFOX, 5, false);
expect(needsHomeScreen()).toBe(false);
});
});
@@ -0,0 +1,64 @@
/**
* The Web Push card with a subscription the browser already holds.
*
* The card reads its state from the browser alone. When the server row was
* lost, or the channel had been switched off by failed sends, the card said
* "enabled" and no reminder came; opening it now sends the subscription
* again, which stores the row and turns the channel back on.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { WebPushCard } from "../web-push-card";
const SUBSCRIPTION = {
endpoint: "https://push.example.test/send/abc",
keys: { p256dh: "p256dh-key", auth: "auth-key" },
};
function holdSubscription(): void {
const subscription = { toJSON: () => SUBSCRIPTION };
// Headless Chromium answers "denied" without a prompt.
vi.spyOn(Notification, "permission", "get").mockReturnValue("granted");
vi.spyOn(navigator.serviceWorker, "getRegistration").mockResolvedValue({
pushManager: { getSubscription: async () => subscription },
} as unknown as ServiceWorkerRegistration);
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("WebPushCard", () => {
it("sends the browser's existing subscription to the server when it opens", async () => {
holdSubscription();
const api = stubFetch().on("POST", "/api/notifications/web-push", {
data: { subscribed: true },
});
const { screen } = await renderWithApp(<WebPushCard />);
await expect
.poll(() => api.calls("POST", "/api/notifications/web-push").length)
.toBe(1);
expect(api.calls("POST", "/api/notifications/web-push")[0].body).toEqual(
SUBSCRIPTION,
);
await expect.element(screen.getByText("Disable push")).toBeVisible();
});
it("keeps showing push as on when that request fails", async () => {
holdSubscription();
stubFetch().on("POST", "/api/notifications/web-push", {
status: 403,
error: "Demo mode: modifications are disabled",
});
const { screen } = await renderWithApp(<WebPushCard />);
await expect.element(screen.getByText("Disable push")).toBeVisible();
expect(document.querySelector('[role="alert"]')).toBeNull();
});
});
@@ -52,7 +52,7 @@ describe("claimErrorKey", () => {
"auth.claim.signedInTitle",
],
[new ApiError("x", 403, {}), "auth.claim.oidcOnly"],
[new ApiError("x", 429, {}), "auth.claim.errorRateLimit"],
[new ApiError("x", 429, {}), "auth.signInRateLimited"],
[new ApiError("x", 500, {}), "auth.claim.errorFailed"],
[new Error("offline"), "auth.claim.errorOffline"],
])("maps %o", (err, key) => {
+34 -34
View File
@@ -10,6 +10,10 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
saveWebPushSubscription,
unsubscribeWebPush,
} from "@/lib/notifications/web-push-subscription";
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
@@ -24,10 +28,25 @@ function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
return buffer;
}
/**
* iPhone and iPad offer Web Push only to HealthLog opened from the Home
* Screen; in a Safari tab the API is missing. iPadOS Safari reports itself
* as a Mac, so a Mac with a touch screen counts too.
*/
export function needsHomeScreen(): boolean {
const ua = navigator.userAgent;
const appleTouch =
/iPhone|iPad|iPod/.test(ua) ||
(/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
return appleTouch && !window.matchMedia("(display-mode: standalone)").matches;
}
type Unsupported = "browser" | "install" | null;
export function WebPushCard() {
const { t } = useTranslations();
const [isSubscribed, setIsSubscribed] = useState(false);
const [isSupported, setIsSupported] = useState(true);
const [unsupported, setUnsupported] = useState<Unsupported>(null);
const [isDenied, setIsDenied] = useState(false);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
@@ -40,7 +59,7 @@ export function WebPushCard() {
// the effect body is rejected.
async function checkSubscription() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
setIsSupported(false);
setUnsupported(needsHomeScreen() ? "install" : "browser");
setLoading(false);
return;
}
@@ -57,6 +76,11 @@ export function WebPushCard() {
if (registration) {
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
// Sent again on every open: the server may have lost the row or
// switched the channel off, and the browser cannot tell.
if (subscription) {
void saveWebPushSubscription(subscription).catch(() => null);
}
}
} catch {
// Ignore errors during check
@@ -95,20 +119,7 @@ export function WebPushCard() {
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
});
const subJson = subscription.toJSON();
const res = await apiFetchRaw("/api/notifications/web-push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: {
p256dh: subJson.keys?.p256dh ?? "",
auth: subJson.keys?.auth ?? "",
},
}),
});
const res = await saveWebPushSubscription(subscription);
if (res.ok) {
setIsSubscribed(true);
setMsg(t("settings.webPushSubscribed"));
@@ -136,21 +147,7 @@ export function WebPushCard() {
setMsgType(null);
try {
const registration =
await navigator.serviceWorker.getRegistration("/sw.js");
if (registration) {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await apiFetchRaw("/api/notifications/web-push", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
}
}
await unsubscribeWebPush();
setIsSubscribed(false);
setMsg(t("settings.webPushUnsubscribed"));
setMsgType("success");
@@ -169,7 +166,7 @@ export function WebPushCard() {
// carries a status badge.
const cardAction = loading ? (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
) : !isSupported || isDenied ? null : (
) : unsupported || isDenied ? null : (
<SettingsCardActions>
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
@@ -212,9 +209,12 @@ export function WebPushCard() {
description={t("settings.webPushDescription")}
/>
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
{(!loading && unsupported) || (!loading && isDenied) || msg ? (
<div className="space-y-4">
{!loading && !isSupported ? (
{!loading && unsupported === "install" ? (
// Steps to follow: content, not meta.
<p className="text-sm">{t("settings.webPushInstallFirst")}</p>
) : !loading && unsupported ? (
<p className="text-muted-foreground text-sm">
{t("settings.webPushNotSupported")}
</p>
@@ -55,6 +55,11 @@ import {
useAuth,
} from "../use-auth";
/** What `/api/auth/me` answers once the session is gone. */
const SESSION_GONE = new ApiError("Unauthorized", 401, {
errorCode: "auth.missing",
});
// Node test env has no localStorage — back it with a Map.
const store = new Map<string, string>();
beforeEach(() => {
@@ -97,9 +102,20 @@ describe("isAuthVerdictUnknown — failure classification", () => {
expect(isAuthVerdictUnknown(new ApiError("boom", 500))).toBe(true);
});
it("treats 401 and 403 as the real session-end verdict", () => {
expect(isAuthVerdictUnknown(new ApiError("unauthorized", 401))).toBe(false);
expect(isAuthVerdictUnknown(new ApiError("forbidden", 403))).toBe(false);
it("treats HealthLog's 401 and 403 as the real session-end verdict", () => {
expect(isAuthVerdictUnknown(SESSION_GONE)).toBe(false);
expect(
isAuthVerdictUnknown(
new ApiError("forbidden", 403, { errorCode: "http.forbidden" }),
),
).toBe(false);
});
it("treats a 401 or 403 without HealthLog's envelope as no verdict", () => {
// A forward-auth proxy or a firewall in front of the instance answers
// with its own page; the session behind it may be fine.
expect(isAuthVerdictUnknown(new ApiError("Unauthorized", 401))).toBe(true);
expect(isAuthVerdictUnknown(new ApiError("Forbidden", 403))).toBe(true);
});
});
@@ -128,7 +144,7 @@ describe("useAuth — last-known state on a non-verdict failure", () => {
it("reads a true 401 as session end — wipe and redirect may proceed", () => {
store.set("healthlog-was-authenticated", "1");
queryState.isError = true;
queryState.error = new ApiError("unauthorized", 401);
queryState.error = SESSION_GONE;
const auth = useAuth();
expect(auth.isAuthenticated).toBe(false);
@@ -136,6 +152,40 @@ describe("useAuth — last-known state on a non-verdict failure", () => {
});
});
describe("useAuth — a verdict on a refetch", () => {
it("ends the session on a 401 although the cache still holds the account", () => {
store.set("healthlog-was-authenticated", "1");
queryState.data = { id: "u1", username: "someone" };
queryState.isError = true;
queryState.error = SESSION_GONE;
const auth = useAuth();
expect(auth.isAuthenticated).toBe(false);
expect(auth.user).toBeNull();
expect(auth.isAuthUnknown).toBe(false);
});
it("keeps the cached account through a 401 from something in front of the server", () => {
queryState.data = { id: "u1", username: "someone" };
queryState.isError = true;
queryState.error = new ApiError("Unauthorized", 401);
const auth = useAuth();
expect(auth.isAuthenticated).toBe(true);
expect(auth.user).toEqual({ id: "u1", username: "someone" });
});
it("keeps the cached account through a network failure on a refetch", () => {
queryState.data = { id: "u1", username: "someone" };
queryState.isError = true;
queryState.error = new TypeError("Failed to fetch");
const auth = useAuth();
expect(auth.isAuthenticated).toBe(true);
expect(auth.user).toEqual({ id: "u1", username: "someone" });
});
});
describe("session-end marker lifecycle", () => {
it("clearCachesForSessionEnd drops the marker so the next cold offline launch lands on the login gate", () => {
store.set("healthlog-was-authenticated", "1");
+203
View File
@@ -0,0 +1,203 @@
/**
* Signing out: what the browser is left with when the server answers.
*
* `useMutation` is replaced by a stand-in that hands back the options, so the
* test drives `mutationFn`, `onSuccess` and `onError` the way React Query
* does, against the real fetch wrapper and a stubbed `fetch`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
interface LogoutOptions {
mutationFn: () => Promise<void>;
onSuccess: () => void;
onError: (failure: unknown) => void;
}
const captured: { options: LogoutOptions | null } = { options: null };
const replace = vi.fn();
const clear = vi.fn();
const showErrorToast = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(),
useQueryClient: () => ({ clear }),
useMutation: (options: LogoutOptions) => {
captured.options = options;
return { mutate: vi.fn(), isPending: false };
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace }),
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/errors/error-toast", () => ({
showErrorToast: (...args: unknown[]) => showErrorToast(...args),
}));
vi.mock("@/lib/pwa/query-persister", () => ({
clearOfflineCachesForSessionEnd: vi.fn().mockResolvedValue(undefined),
}));
import { useLogout } from "../use-auth";
const fetchMock =
vi.fn<(input: string, init?: RequestInit) => Promise<Response>>();
function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
/** What React Query does with the options: run, then settle one way. */
async function settle(options: LogoutOptions): Promise<void> {
try {
await options.mutationFn();
} catch (failure) {
options.onError(failure);
return;
}
options.onSuccess();
}
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
captured.options = null;
});
describe("useLogout", () => {
it("clears the caches and replaces the page with the sign-in page once the server signed out", async () => {
fetchMock.mockResolvedValue(json(200, { data: { loggedOut: true } }));
useLogout();
await settle(captured.options!);
expect(fetchMock).toHaveBeenCalledWith(
"/api/auth/logout",
expect.objectContaining({ method: "POST" }),
);
expect(clear).toHaveBeenCalledTimes(1);
expect(replace).toHaveBeenCalledWith("/auth/login");
expect(showErrorToast).not.toHaveBeenCalled();
});
/** A browser holding a push subscription; returns the call order log. */
function holdSubscription(order: string[]) {
const unsubscribe = vi.fn(async () => {
order.push("browser unsubscribe");
return true;
});
const subscription = {
endpoint: "https://push.example.com/send/abc",
toJSON: () => ({
endpoint: "https://push.example.com/send/abc",
keys: { p256dh: "p256dh", auth: "auth" },
}),
unsubscribe,
};
vi.stubGlobal("window", { PushManager: class {} });
vi.stubGlobal("navigator", {
serviceWorker: {
getRegistration: async () => ({
pushManager: { getSubscription: async () => subscription },
}),
},
});
return { subscription, unsubscribe };
}
it("takes this browser's push subscription off the account and out of the browser", async () => {
// Left in place, the browser kept showing the signed-out account's
// medication reminders, also after someone else signed in. The server
// row goes while the session can still name it; the browser's own only
// once the sign-out is confirmed.
const order: string[] = [];
const { subscription } = holdSubscription(order);
fetchMock.mockImplementation(async (url, init) => {
order.push(`${init?.method} ${url}`);
return json(200, { data: {} });
});
useLogout();
await settle(captured.options!);
expect(order).toEqual([
"DELETE /api/notifications/web-push",
"POST /api/auth/logout",
"browser unsubscribe",
]);
const deleteCall = fetchMock.mock.calls[0];
expect(JSON.parse(String(deleteCall[1]?.body))).toEqual({
endpoint: subscription.endpoint,
});
// A connection that carries nothing must not hold the sign-out.
expect(deleteCall[1]?.signal).toBeInstanceOf(AbortSignal);
expect(replace).toHaveBeenCalledWith("/auth/login");
});
it("keeps push working when the server refuses the sign-out", async () => {
const order: string[] = [];
const { unsubscribe } = holdSubscription(order);
fetchMock.mockImplementation(async (url, init) => {
order.push(`${init?.method} ${url}`);
return url === "/api/auth/logout"
? json(503, { data: null, error: "Unavailable" })
: json(200, { data: {} });
});
useLogout();
await settle(captured.options!);
expect(order).toEqual([
"DELETE /api/notifications/web-push",
"POST /api/auth/logout",
"POST /api/notifications/web-push",
]);
expect(unsubscribe).not.toHaveBeenCalled();
expect(showErrorToast).toHaveBeenCalledTimes(1);
});
it("still signs out when removing the subscription fails", async () => {
vi.stubGlobal("window", { PushManager: class {} });
vi.stubGlobal("navigator", {
serviceWorker: {
getRegistration: async () => {
throw new Error("no worker");
},
},
});
fetchMock.mockResolvedValue(json(200, { data: { loggedOut: true } }));
useLogout();
await settle(captured.options!);
expect(fetchMock).toHaveBeenCalledWith(
"/api/auth/logout",
expect.objectContaining({ method: "POST" }),
);
expect(replace).toHaveBeenCalledWith("/auth/login");
});
it("stays on the page and says so when the server refuses", async () => {
// The demo instance answered 403 here; the page showed the sign-in form
// while the session cookie still opened every record page.
fetchMock.mockResolvedValue(json(403, { data: null, error: "Forbidden" }));
useLogout();
await settle(captured.options!);
expect(replace).not.toHaveBeenCalled();
expect(clear).not.toHaveBeenCalled();
expect(showErrorToast).toHaveBeenCalledWith(
"auth.signOutFailed",
expect.objectContaining({ cause: expect.anything() }),
);
});
});
+44 -25
View File
@@ -9,7 +9,7 @@ import {
import { useRouter } from "next/navigation";
import { showErrorToast } from "@/lib/errors/error-toast";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch";
import { apiGet, apiFetch, ApiError } from "@/lib/api/api-fetch";
import { adoptRecordFenceState } from "@/lib/api/record-fence";
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
import { useMounted } from "@/hooks/use-mounted";
@@ -30,6 +30,7 @@ import type { TourProgress } from "@/lib/onboarding/tour-progress";
import { clearLastDay } from "@/lib/day/last-day";
import { clearCoachDeleteJournal } from "@/lib/ai/coach/delete-journal";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import { signOutWithoutWebPush } from "@/lib/notifications/web-push-subscription";
import {
setRecordScope,
setRefusedRecordScope,
@@ -337,18 +338,22 @@ function clearWasAuthenticated(): void {
* True when an `/api/auth/me` failure carries NO verdict on the session —
* a network-level failure (fetch `TypeError` offline, abort/timeout), the
* service worker's synthetic 503 offline envelope (`meta.offline`), the
* browser reporting itself offline, or a server-side 5xx. None of these mean
* "logged out"; only an explicit 401/403 does. Treating them as session end
* used to redirect an offline relaunch to /auth/login and wipe the offline
* caches — destroying exactly the installed-PWA-opened-offline scenario the
* SW + query persister exist for.
* browser reporting itself offline, a server-side 5xx, or a 401/403 that
* HealthLog did not write. None of these mean "logged out"; only HealthLog's
* own 401/403 does. Treating them as session end used to redirect an offline
* relaunch to /auth/login and wipe the offline caches — destroying exactly
* the installed-PWA-opened-offline scenario the SW + query persister exist
* for.
*/
export function isAuthVerdictUnknown(error: unknown): boolean {
if (error instanceof ApiError) {
// 5xx (incl. the SW's synthetic 503 with `meta.offline: true`) and any
// other non-auth status: the server never ruled on the session. Only an
// explicit 401/403 is a verdict.
return error.status !== 401 && error.status !== 403;
// other non-auth status: the server never ruled on the session.
if (error.status !== 401 && error.status !== 403) return true;
// HealthLog's error envelope always names a code. A 401/403 without one
// comes from something in front of the instance (a forward-auth proxy,
// a firewall), which says nothing about this session.
return typeof error.meta?.errorCode !== "string";
}
// Everything below the HTTP layer is transport-level by construction:
// fetch `TypeError` (offline / DNS / connection refused), `AbortError` /
@@ -527,7 +532,7 @@ export function useAuth() {
});
// v1.27.x offline fix — classify a failed `/api/auth/me` before acting on
// it. Only an explicit 401/403 means "session ended"; a network-level
// it. Only HealthLog's own 401/403 means "session ended"; a network-level
// failure (offline relaunch, SW 503 offline envelope, timeout) or a 5xx
// carries no verdict, so the hook holds the LAST-KNOWN auth state (the
// localStorage marker) instead of flipping to unauthenticated. Pre-fix, an
@@ -535,11 +540,15 @@ export function useAuth() {
// /auth/login and wiped every offline cache (data 7→0 entries, pages 1→0
// in the audit measurement), making the whole offline story unreachable.
const verdictUnknown = query.isError && isAuthVerdictUnknown(query.error);
const isAuthenticated =
!!query.data || (verdictUnknown && readWasAuthenticated());
// HealthLog's 401/403 on a refetch ends the session although the cache
// still holds the last good payload: React Query keeps `data` when a
// refetch fails.
const sessionEnded = query.isError && !verdictUnknown;
const user = sessionEnded ? null : (query.data ?? null);
const isAuthenticated = !!user || (verdictUnknown && readWasAuthenticated());
return {
user: query.data ?? null,
user,
isLoading: query.isLoading,
isAuthenticated,
/**
@@ -549,7 +558,7 @@ export function useAuth() {
* wipe: a wipe without a 401/403 verdict would destroy the offline
* caches on every network blip.
*/
isAuthUnknown: !query.data && verdictUnknown,
isAuthUnknown: !user && verdictUnknown,
error: query.error,
refetch: query.refetch,
};
@@ -617,10 +626,10 @@ export function useAccountOnceMounted(): AuthUser | null {
* former explicit `setQueryData(null)` + `invalidateQueries(["auth"])`.
*
* Then `clearOfflineCachesForSessionEnd()` wipes the persisted layers — the
* IndexedDB query snapshot, the SW offline read-data cache (`healthlog-data-*`),
* and the SW page cache (`healthlog-pages-*`, cached navigation HTML). The static
* cache (hashed chunks, icons) carries no PII and stays intact to avoid a
* needless re-download. Best-effort; never blocks the redirect.
* IndexedDB query snapshot, the SW page cache (`healthlog-pages-*`, cached
* navigation HTML) and any API cache an older worker left (`healthlog-data-*`).
* The static cache (catalogs, icons, fonts, images) carries no PII and stays
* intact to avoid a needless re-download. Best-effort; never blocks the redirect.
*/
export function clearCachesForSessionEnd(queryClient: QueryClient): void {
queryClient.clear();
@@ -642,22 +651,32 @@ export function clearCachesForSessionEnd(queryClient: QueryClient): void {
void clearOfflineCachesForSessionEnd();
}
/**
* End the session on the server, for every sign-out button. Takes this
* browser's push subscription with it. Throws on any non-2xx answer: a
* refused sign-out leaves the session alive, and showing the sign-in page
* then would claim otherwise.
*/
export function signOutRequest(): Promise<void> {
return signOutWithoutWebPush(() =>
apiFetch("/api/auth/logout", { method: "POST" }),
);
}
export function useLogout() {
const queryClient = useQueryClient();
const router = useRouter();
const { t } = useTranslations();
return useMutation({
mutationFn: async () => {
await apiFetchRaw("/api/auth/logout", { method: "POST" });
},
mutationFn: signOutRequest,
onSuccess: () => {
clearCachesForSessionEnd(queryClient);
router.push("/auth/login");
// Replace, so Back does not reopen the last page signed out, and the
// next sign-in on this device is not sent there.
router.replace("/auth/login");
},
// v1.16.4 — a network-failed logout used to do nothing at all (the
// menu closed, the session stayed); a toast names the failure.
onError: (failure) =>
showErrorToast(t("common.networkError"), { cause: failure }),
showErrorToast(t("auth.signOutFailed"), { cause: failure }),
});
}
+80 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "../api-fetch";
import { localizedApiError } from "../localized-error";
import { localizedApiError, localizedSignInError } from "../localized-error";
import de from "../../../../messages/de.json";
import en from "../../../../messages/en.json";
import es from "../../../../messages/es.json";
@@ -234,3 +234,82 @@ describe("localizedApiError", () => {
});
});
});
describe("localizedSignInError", () => {
const enT = translator(en as Catalog);
it("reads a wrong password or code as the step's failure, not an ended session", () => {
const error = new ApiError("Invalid credentials", 401, {
errorCode: "http.unauthenticated",
});
expect(localizedSignInError(error, enT, "auth.loginFailed")).toBe(
"Login failed",
);
});
it.each([
["de", de],
["en", en],
["es", es],
["fr", fr],
["it", itCatalog],
["ko", ko],
["nl", nl],
["pl", pl],
["pt-BR", ptBR],
["ru", ru],
])("reads a lockout as one in %s", (_, locale) => {
const localT = translator(locale as Catalog);
const error = new ApiError("Too many login attempts", 429, {
errorCode: "http.rate_limited",
});
const sentence = localizedSignInError(error, localT, "auth.loginFailed");
expect(sentence).toBe(localT("auth.signInRateLimited"));
expect(sentence).not.toBe("auth.signInRateLimited");
});
it.each([
["de", de],
["en", en],
["es", es],
["fr", fr],
["it", itCatalog],
["ko", ko],
["nl", nl],
["pl", pl],
["pt-BR", ptBR],
["ru", ru],
])(
"names the passkey, single sign-on and registration refusals in %s",
(_, locale) => {
const localT = translator(locale as Catalog);
for (const [status, code] of [
[401, "passkey.challenge.expired"],
[404, "passkey.unknown"],
[422, "passkey.response.invalid"],
[403, "oidc_only"],
[409, "auth.already_authenticated"],
[409, "auth.register.taken"],
[403, "auth.register.invite_invalid"],
] as const) {
const error = new ApiError("raw English server prose", status, {
errorCode: code,
});
expect(
localizedSignInError(error, localT, "auth.loginFailed"),
code,
).toBe(localT(`apiErrors.${code}`));
expect(localT(`apiErrors.${code}`), code).not.toBe(`apiErrors.${code}`);
}
},
);
it("keeps the sentence of a code the step names itself", () => {
const error = new ApiError("raw English server prose", 401, {
errorCode: "auth.reproof.failed",
});
expect(localizedSignInError(error, enT, "auth.loginFailed")).toMatch(
/did not verify/,
);
});
});
+20
View File
@@ -24,3 +24,23 @@ export function localizedApiError(
: defaultErrorCodeForStatus(error.status);
return translateCode(t, "apiErrors", errorCode, fallbackKey);
}
/**
* A refused sign-in step (password, second factor, passkey). These refusals
* mostly carry only their status's generic code, and here a 401 means a
* wrong password or code, not an ended session, and a 429 a lockout of
* several minutes. A code of the step's own keeps its sentence.
*/
export function localizedSignInError(
error: unknown,
t: Translate,
fallbackKey: string,
): string {
if (error instanceof ApiError) {
const code = error.meta?.errorCode;
const generic = typeof code !== "string" || code.startsWith("http.");
if (generic && error.status === 429) return t("auth.signInRateLimited");
if (generic && error.status === 401) return t(fallbackKey);
}
return localizedApiError(error, t, fallbackKey);
}
+12 -6
View File
@@ -430,12 +430,18 @@ export async function getSession(): Promise<{
// already had, and it is coherent: that browser is showing the older
// language too, because the cookie outranks the column on its first
// paint as well.
void prisma.user
.update({
where: { id: session.user.id },
data: { locale: cookieLocale },
})
.catch(() => {});
//
// A demo is one account every visitor shares: there the language stays
// in this visitor's cookie and never reaches the row the next visitor
// starts from.
if (process.env.DEMO_MODE !== "true") {
void prisma.user
.update({
where: { id: session.user.id },
data: { locale: cookieLocale },
})
.catch(() => {});
}
// Keep THIS request coherent — a handler resolving the locale from the
// returned row must not answer with the value we just replaced.
session.user.locale = cookieLocale;
-19
View File
@@ -23,8 +23,6 @@ import { refreshDoseDay } from "@/lib/medications/dose-day-refresh";
import { unstableExternalIdMeta } from "@/lib/validations/external-id";
import type { InjectionSiteValue } from "@/lib/validations/medication";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import {
createBatchState,
type BatchState,
@@ -198,23 +196,6 @@ async function settleLandedRows(input: BatchInput, state: BatchState) {
userId: user.id,
originDeviceToken: input.originDeviceToken,
});
// PWA-only counterpart: close the still-pending dose-due
// reminders for every affected slot over Web Push and refresh the app
// badge once with the post-batch outstanding-dose count. Best-effort,
// fire-and-forget — a clear miss never affects the batch response.
const affectedSlots = Array.from(state.syncSlots.values());
void (async () => {
const badgeCount = await countOutstandingDosesToday(
user.id,
user.timezone,
);
await dispatchMedicationIntakeWebClearBulk({
userId: user.id,
slots: affectedSlots,
badgeCount,
});
})();
}
}
-46
View File
@@ -1,46 +0,0 @@
/**
* v1.18.4 — server-authoritative count of a user's still-outstanding doses
* for the current local day. Drives the PWA app badge (`navigator.setAppBadge`)
* so the installed icon mirrors how many doses still need logging — the
* free-tier equivalent of the iOS widget's pending count.
*
* Outstanding = a today-scheduled `MedicationIntakeEvent` that is neither
* taken (`takenAt == null`) nor skipped (`skipped == false`) nor swept by the
* miss cutoff (`autoMissed == false`). Pending rows are minted by
* `projectTodayIntakesAndRecompute`; this is a read-only count over the same
* window, so callers that have just projected today's rows get an accurate
* total.
*/
import { prisma } from "@/lib/db";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
/**
* Count the user's outstanding (pending) doses for today in their timezone.
* Best-effort: any failure returns 0 so a badge update never blocks a caller.
*/
export async function countOutstandingDosesToday(
userId: string,
tz: string,
): Promise<number> {
try {
const { start, end } = getUserTodayBounds(new Date(), tz);
return await prisma.medicationIntakeEvent.count({
where: {
userId,
scheduledFor: { gte: start, lte: end },
takenAt: null,
skipped: false,
autoMissed: false,
// A tombstoned placeholder is gone for every read surface; it must
// not keep a badge count alive.
deletedAt: null,
// v1.39.1 (#1033) — nothing of a medication with intake tracking
// off is outstanding.
...TRACKED_INTAKE_EVENT_WHERE,
},
});
} catch {
return 0;
}
}
@@ -23,8 +23,6 @@ import {
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
import {
@@ -203,11 +201,7 @@ async function recordAfterWrite(
}
/** The tails that never affect the response. */
function startTails(
ctx: RecordIntakeContext,
skipped: boolean,
event: WrittenIntake["event"],
): void {
function startTails(ctx: RecordIntakeContext, skipped: boolean): void {
const { user, medicationId: id } = ctx;
// (#22) — silent cross-device intake sync: wake the user's OTHER iOS
// devices so a running Live Activity / widget reconciles this dose.
@@ -217,22 +211,6 @@ function startTails(
originDeviceToken: ctx.originDeviceToken,
});
// PWA counterpart of the sync wake above: a dose resolved here (taken or
// skipped) closes the still-pending dose-due Web Push reminder for the
// slot and refreshes the app badge. The other intake routes have carried
// this since v1.18.4; this route is the replay target of the iOS offline
// queue, so a drained dose must clear the web reminder the same way.
// Best-effort, fire-and-forget — the canonical row is already persisted.
void (async () => {
const badgeCount = await countOutstandingDosesToday(user.id, user.timezone);
await dispatchMedicationIntakeWebClear({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor.toISOString(),
badgeCount,
});
})();
// "Somebody else marked your dose". This route has no snooze arm,
// so the state is whichever of the two markings the payload carried. The
// helper refuses on self, so a person marking their own dose is unaffected.
@@ -304,7 +282,7 @@ export async function recordIntake(
await settleInventory(ctx, skipped, written);
await recordAfterWrite(ctx, skipped, written.event);
startTails(ctx, skipped, written.event);
startTails(ctx, skipped);
return { kind: "recorded", event: written.event };
}
@@ -19,9 +19,7 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
import { restoreForIntake } from "@/lib/medications/inventory/consumption";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import {
refuseDelegatedChange,
refusesUntrackedIntake,
@@ -95,7 +93,7 @@ async function recordAfterUpdate(
data: IntakeStatusUpdate,
updated: UpdatedRow | null,
userTzForHook: string,
): Promise<Date | undefined> {
): Promise<void> {
const { user, existing } = ctx;
const { intakeId, status } = data;
await auditLog("medications.intake.update", {
@@ -135,15 +133,12 @@ async function recordAfterUpdate(
tz: userTzForHook,
});
}
return movedTo;
}
/** The tails that never affect the response. */
function startTails(
ctx: StatusContext,
status: IntakeStatusUpdate["status"],
movedTo: Date | undefined,
userTzForHook: string,
): void {
const { user, existing } = ctx;
// (#22) — silent cross-device intake sync. Wake the user's OTHER
@@ -157,27 +152,6 @@ function startTails(
originDeviceToken: ctx.originDeviceToken,
});
// PWA-only equivalent of the Live Activity end: when a dose is
// resolved (taken / skipped), push a `type:"clear"` to the user's Web Push
// subscriptions so the still-pending dose-due reminder for this slot is
// closed by the service worker (matched on the stable slot tag) and the app
// badge re-reflects the outstanding-dose count. A snooze leaves the dose
// outstanding, so it neither clears the reminder nor changes the badge.
if (status === "taken" || status === "skipped") {
void (async () => {
const badgeCount = await countOutstandingDosesToday(
user.id,
userTzForHook,
);
await dispatchMedicationIntakeWebClear({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(),
badgeCount,
});
})();
}
// "Somebody else marked your dose". The helper refuses on self and
// on a snooze, resolves both names itself, and never throws.
//
@@ -243,8 +217,8 @@ export async function updateIntakeStatus(
updated = await applySkipOrSnooze(ctx, data);
}
const movedTo = await recordAfterUpdate(ctx, data, updated, userTzForHook);
startTails(ctx, status, movedTo, userTzForHook);
await recordAfterUpdate(ctx, data, updated, userTzForHook);
startTails(ctx, status);
return { kind: "updated", row: updated };
}
@@ -207,6 +207,66 @@ describe("dispatchNotification — soft reject (web-push 429)", () => {
});
});
describe("dispatchNotification — nobody to send to", () => {
// Turning push off on the last browser deletes its subscription and leaves
// the channel on. Counted as failures, the next five reminders switched the
// channel off for good, and subscribing again later did not bring it back.
// Not counted either, the jobs that retry until delivered asked again every
// minute, and a watchdog read the attempt as an announced alert.
it.each([
["WEB_PUSH", "web_push_no_subscriptions"],
["WEB_PUSH", "web_push_vapid_not_configured"],
["APNS", "apns_no_devices"],
["APNS", "apns_not_configured"],
] as const)(
"%s answering %s rests the channel without counting a failure or an attempt",
async (type, reason) => {
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
{
...makeChannel({ type, consecutiveFailures: 4 }),
createdAt: new Date(0),
updatedAt: new Date(0),
disabledReason: null,
lastSuccessAt: null,
lastFailureAt: null,
lastFailureReason: null,
},
]);
const sender = type === "APNS" ? sendViaApnsMock : sendViaWebPushMock;
sender.mockResolvedValueOnce({ ok: false, hardReject: false, reason });
const outcome = await dispatchNotification({
eventType: "MEDICATION_REMINDER",
userId: "u-1",
title: "t",
message: "m",
});
expect(sender).toHaveBeenCalledTimes(1);
expect(outcome).toEqual({
dispatched: false,
channelsAttempted: 0,
channelsSucceeded: 0,
});
expect(prisma.notificationChannel.update).toHaveBeenCalledTimes(1);
const { data } = vi.mocked(prisma.notificationChannel.update).mock
.calls[0][0] as { data: Record<string, unknown> };
// The reason for the status screen and an hour's rest; the counter and
// the switch stay as they were.
expect(data).toEqual({
lastFailureAt: expect.any(Date),
lastFailureReason: reason,
nextRetryAt: expect.any(Date),
});
expect(
(data.nextRetryAt as Date).getTime() -
(data.lastFailureAt as Date).getTime(),
).toBe(60 * 60 * 1000);
expect(auditLog).not.toHaveBeenCalled();
},
);
});
describe("dispatchNotification — bounded APNs fallback", () => {
it("continues to the next channel after an APNs timeout", async () => {
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
@@ -1,298 +0,0 @@
/**
* v1.18.4 — PWA-only dose lifecycle over Web Push.
*
* Verifies the three pieces a self-hoster without an Apple Developer account
* relies on:
* 1. The dose-due reminder push carries a STABLE per-slot tag + badge count.
* 2. The clear-on-taken push reuses the SAME tag (so the SW closes the
* pending reminder — the equivalent of ending a Live Activity) and
* refreshes the badge.
* 3. A Web Push channel still delivers when APNs is absent.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// safeFetch's requirePublicHost path drives undici's OWN fetch (version-locked
// with its dispatcher). Delegate it to the global fetch these tests stub so the
// existing interception still applies.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
const { generateRequestDetailsMock, findManyMock, deleteManyMock } = vi.hoisted(
() => ({
generateRequestDetailsMock: vi.fn(),
findManyMock: vi.fn(),
deleteManyMock: vi.fn(),
}),
);
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addWarning: vi.fn(),
addExternalCall: vi.fn(),
setError: vi.fn(),
addMeta: vi.fn(),
}),
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
recordPushAttemptForPayload: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
pushSubscription: {
findMany: findManyMock,
deleteMany: deleteManyMock,
},
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (v: string) => v,
encrypt: (v: string) => v,
}));
vi.mock("@/lib/notifications/vapid-config", () => ({
getVapidConfig: () =>
Promise.resolve({
subject: "mailto:a@b.c",
publicKey: "pub",
privateKey: "priv",
}),
}));
vi.mock("@/lib/validations/notifications", () => ({
isPublicUrl: () => true,
}));
vi.mock("web-push", () => ({
setVapidDetails: vi.fn(),
// The sender signs + encrypts here and dials through `safeFetch`; the
// payload assertions below read the same second argument as before.
generateRequestDetails: (...args: unknown[]) => {
generateRequestDetailsMock(...args);
return {
endpoint: "https://push.example.com/x",
method: "POST",
headers: {},
body: null,
};
},
}));
import { sendViaWebPush } from "../senders/web-push";
import { dispatchMedicationIntakeWebClear } from "../web-push-clear";
import { medicationDoseTag } from "../dose-tag";
const SUB = {
id: "s1",
endpoint: "https://push.example.com/x",
p256dh: "a",
auth: "b",
};
beforeEach(() => {
generateRequestDetailsMock.mockReset();
findManyMock.mockReset();
deleteManyMock.mockReset();
deleteManyMock.mockResolvedValue({ count: 0 });
});
afterEach(() => vi.clearAllMocks());
describe("medicationDoseTag", () => {
it("is stable across ISO instants that differ only in milliseconds", () => {
const a = medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z");
const b = medicationDoseTag("med-1", "2026-06-18T07:00:00Z");
expect(a).toBe(b);
expect(a).toBe("med:med-1:2026-06-18T07:00:00.000Z");
});
});
describe("web-push reminder push — stable tag + badge", () => {
it("uses metadata.webPushTag as the notification tag and carries the badge", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
const tag = medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z");
const res = await sendViaWebPush("user-1", {
eventType: "MEDICATION_REMINDER",
userId: "user-1",
title: "Time for your dose",
message: "Ramipril 5mg",
metadata: { webPushTag: tag, badgeCount: 2, url: "/medications/med-1" },
});
expect(res.ok).toBe(true);
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.tag).toBe(tag);
expect(body.badge).toBe(2);
expect(body.url).toBe("/medications/med-1");
});
it("discreet mode still wins over a stable tag (no event name leak)", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await sendViaWebPush("user-1", {
eventType: "CYCLE_PERIOD_SOON",
userId: "user-1",
title: "HealthLog reminder",
message: "HealthLog reminder",
discreet: true,
metadata: { webPushTag: "med:should-not-leak" },
});
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.tag).toBe("REMINDER");
});
it("uses an ordinary landing route without actions for a managed Guardian", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await sendViaWebPush("guardian-1", {
eventType: "MEDICATION_REMINDER",
userId: "record-1",
recordUserId: "record-1",
recipientUserId: "guardian-1",
title: "Time for your dose",
message: "Ramipril 5mg",
urgent: true,
metadata: { url: "/medications/med-1" },
});
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.url).toBe("/");
expect(body.actions).toBeUndefined();
});
});
describe("web-push tap target", () => {
async function sentBody(payload: Parameters<typeof sendViaWebPush>[1]) {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await sendViaWebPush("user-1", payload);
return JSON.parse(generateRequestDetailsMock.mock.calls[0][1] as string);
}
it("carries the dispatcher's resolved tap path as data url", async () => {
const body = await sentBody({
eventType: "MOOD_REMINDER",
userId: "user-1",
title: "Log your mood",
message: "How are you feeling today?",
tap: { path: "/mood?add=1", url: null, label: "Open" },
});
expect(body.url).toBe("/mood?add=1");
});
it("refuses a protocol-relative metadata url when no tap was resolved", async () => {
const body = await sentBody({
eventType: "SYSTEM_ALERT",
userId: "user-1",
title: "t",
message: "m",
metadata: { url: "//evil.example/x" },
});
expect(body.url).toBe("/");
});
});
describe("clear-on-taken push", () => {
it("emits type:clear with the SAME slot tag as the reminder + badge", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await dispatchMedicationIntakeWebClear({
userId: "user-1",
medicationId: "med-1",
scheduledFor: "2026-06-18T07:00:00.000Z",
badgeCount: 1,
});
expect(generateRequestDetailsMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.type).toBe("clear");
expect(body.tag).toBe(
medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z"),
);
expect(body.badge).toBe(1);
});
it("a count of 0 clears the badge (badge:0 on the wire)", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await dispatchMedicationIntakeWebClear({
userId: "user-1",
medicationId: "med-1",
scheduledFor: "2026-06-18T07:00:00.000Z",
badgeCount: 0,
});
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.badge).toBe(0);
});
it("no subscriptions → no-op, no throw", async () => {
findManyMock.mockResolvedValue([]);
await expect(
dispatchMedicationIntakeWebClear({
userId: "user-1",
medicationId: "med-1",
scheduledFor: "2026-06-18T07:00:00.000Z",
}),
).resolves.toBeUndefined();
expect(generateRequestDetailsMock).not.toHaveBeenCalled();
});
it("reaps expired (410) subscriptions", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 410 }),
);
await dispatchMedicationIntakeWebClear({
userId: "user-1",
medicationId: "med-1",
scheduledFor: "2026-06-18T07:00:00.000Z",
});
expect(deleteManyMock).toHaveBeenCalledWith({
where: { id: { in: ["s1"] } },
});
});
});
@@ -11,8 +11,8 @@
*
* `web-push`'s `sendNotification` dials with its own internal `https.request`,
* which bypasses `safeFetch` entirely and therefore bypasses the pinned
* dispatcher. Both senders now sign and encrypt via `generateRequestDetails`
* — identical VAPID + payload handling — and dial through `safeFetch` with
* dispatcher. The sender signs and encrypts via `generateRequestDetails`
* — identical VAPID + payload handling — and dials through `safeFetch` with
* `requirePublicHost: true`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -76,7 +76,6 @@ vi.mock("web-push", () => ({
}));
import { sendViaWebPush } from "../senders/web-push";
import { dispatchMedicationIntakeWebClear } from "../web-push-clear";
const SUB = {
id: "s1",
@@ -134,15 +133,3 @@ describe("sendViaWebPush egress", () => {
});
});
});
describe("dispatchMedicationIntakeWebClear egress", () => {
it("dials through safeFetch with requirePublicHost too", async () => {
await dispatchMedicationIntakeWebClear({
userId: "user-1",
medicationId: "med-1",
scheduledFor: "2026-06-18T07:00:00.000Z",
});
expect(safeFetchMock).toHaveBeenCalledTimes(1);
expect(optsOfFirstCall()?.requirePublicHost).toBe(true);
});
});
+38
View File
@@ -6,8 +6,11 @@
* soft reject" rules unit-testable and audit-loggable in one place.
*/
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
import { auditLog } from "@/lib/auth/audit";
import { getEvent } from "@/lib/logging/context";
import {
NO_RECIPIENT_COOLDOWN_MS,
nextRetryAt,
shouldAutoDisableAfterTransient,
type SendOutcome,
@@ -129,6 +132,41 @@ export async function recordChannelTransientFailure(
return { autoDisabled: false, nextRetryAt: retryAt };
}
/**
* Record a send that had nobody to go to. Not a failure: the counter stays
* where it is. The reason is kept, so the status screen says why nothing
* arrives, and the channel rests for `NO_RECIPIENT_COOLDOWN_MS`.
*/
export async function recordChannelNoRecipient(
channel: ChannelRef,
reason: string,
now: Date = new Date(),
): Promise<void> {
getEvent()?.addMeta(
`notification_skip_${channel.type.toLowerCase()}_no_recipient`,
reason,
);
await prisma.notificationChannel.update({
where: { id: channel.id },
data: {
lastFailureAt: now,
lastFailureReason: reason,
nextRetryAt: new Date(now.getTime() + NO_RECIPIENT_COOLDOWN_MS),
},
});
}
/** Pairing an iPhone ends a rest that was waiting for one. */
export async function endApnsNoDeviceRest(
db: Pick<Prisma.TransactionClient, "notificationChannel">,
userId: string,
): Promise<void> {
await db.notificationChannel.updateMany({
where: { userId, type: "APNS", lastFailureReason: "apns_no_devices" },
data: { nextRetryAt: null, lastFailureReason: null },
});
}
/**
* Re-enable a channel that was previously auto-disabled. Clears the
* disabled-reason + counter and the cooldown so the next dispatch
+37 -28
View File
@@ -20,6 +20,7 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy";
import {
isChannelInCooldown,
recordChannelHardReject,
recordChannelNoRecipient,
recordChannelSuccess,
recordChannelTransientFailure,
} from "@/lib/notifications/channel-state";
@@ -43,6 +44,24 @@ import {
resolveNotificationDeliveryIdentity,
} from "@/lib/notifications/delivery-identity";
/**
* Sender outcomes that mean there was nobody to send to: no browser
* subscribed, no iPhone paired, no address, or a transport the operator has
* not set up. The sender writes a `skipped` ledger row. The channel has not
* failed, so these leave its failure counter alone; counted as failures,
* turning push off on the last device would switch the channel off for good
* after five reminders. The channel rests for a while instead
* (`recordChannelNoRecipient`), and the send does not count as an attempt.
*/
const NO_RECIPIENT_REASONS: ReadonlySet<string> = new Set([
"web_push_no_subscriptions",
"web_push_vapid_not_configured",
"apns_no_devices",
"apns_not_configured",
"email_no_recipient",
"email_not_configured",
]);
/**
* Dispatch a notification to all enabled channels for a user.
* Best-effort: logs errors but never throws.
@@ -57,6 +76,8 @@ import {
* 6. Call the appropriate sender — which now returns a `SendOutcome` so
* the dispatcher can classify hard rejects (410, blocked-by-user) vs
* soft errors (5xx, 429, network) and update channel state accordingly.
* A send that had nobody to go to (`NO_RECIPIENT_REASONS`) pauses the
* channel without counting a failure.
*
* Also handles legacy Telegram config stored on the User model by
* auto-migrating it to a NotificationChannel record on first dispatch.
@@ -352,9 +373,14 @@ export async function dispatchNotification(
continue;
}
const ref = {
id: channel.id,
userId: channel.userId,
type: channel.type as ChannelType,
};
try {
const prepared = prepareChannelSend(
channel.type as ChannelType,
ref.type,
channel.config,
delivery.recipientUserId,
recipientPayload,
@@ -402,39 +428,26 @@ export async function dispatchNotification(
}
}
if (outcome.ok) {
await recordChannelSuccess({
id: channel.id,
userId: channel.userId,
type: channel.type as ChannelType,
});
await recordChannelSuccess(ref);
channelsSucceeded += 1;
continue;
}
if (outcome.reason && NO_RECIPIENT_REASONS.has(outcome.reason)) {
channelsAttempted -= 1; // nothing went anywhere
await recordChannelNoRecipient(ref, outcome.reason, now);
continue;
}
if (outcome.hardReject) {
await recordChannelHardReject(
{
id: channel.id,
userId: channel.userId,
type: channel.type as ChannelType,
},
outcome,
);
await recordChannelHardReject(ref, outcome);
getEvent()?.addWarning(
`Notification channel ${channel.type} auto-disabled: ${outcome.reason}`,
);
continue;
}
const result = await recordChannelTransientFailure(
{
id: channel.id,
userId: channel.userId,
type: channel.type as ChannelType,
},
outcome,
now,
);
const result = await recordChannelTransientFailure(ref, outcome, now);
getEvent()?.addWarning(
`Notification dispatch failed for channel ${channel.type}: ${outcome.reason}` +
(result.autoDisabled ? " (auto-disabled after 5 failures)" : ""),
@@ -445,11 +458,7 @@ export async function dispatchNotification(
// be no-throw, this is defence-in-depth.)
const message = err instanceof Error ? err.message : String(err);
await recordChannelTransientFailure(
{
id: channel.id,
userId: channel.userId,
type: channel.type as ChannelType,
},
ref,
{ reason: "sender_threw", message },
now,
);
+5 -13
View File
@@ -1,18 +1,10 @@
/**
* v1.18.4 — stable Web Push notification tag for a medication dose slot.
* Stable notification tag for a medication dose slot, `(medicationId,
* scheduledFor instant)`. A repeated reminder for the same dose replaces the
* one already shown instead of stacking a second.
*
* A PWA self-hoster with no Apple Developer account has no Live Activity to
* end when a dose is logged. Instead the server pushes a `type: "clear"`
* web-push that the service worker turns into `getNotifications({ tag })` +
* `notification.close()`. For that to work the REMINDER push and the CLEAR
* push must agree on a single, stable tag per dose slot.
*
* The slot is uniquely identified by `(medicationId, scheduledFor-instant)`:
* - the dose-due reminder fires with `scheduledAt` = the slot's ISO instant,
* - the clear-on-taken fires with `scheduledFor` = the same ISO instant.
*
* `scheduledFor` is normalised through `Date` so a reminder ISO carrying
* milliseconds and a clear ISO without them still collapse to one tag.
* The instant is normalised through `Date`, so an ISO string with and one
* without milliseconds give the same tag.
*/
export function medicationDoseTag(
medicationId: string,
+8
View File
@@ -76,6 +76,14 @@ export const BACKOFF_SCHEDULE_MS: readonly number[] = Object.freeze([
export const MAX_CONSECUTIVE_FAILURES = 5;
/**
* How long a channel rests after a send that had nobody to go to (no
* browser subscribed, no iPhone paired, a transport the operator has not set
* up). The jobs that retry until delivered would otherwise ask again every
* minute. Subscribing or pairing ends the rest at once.
*/
export const NO_RECIPIENT_COOLDOWN_MS = 60 * 60 * 1_000;
/**
* Given the new `consecutiveFailures` value (1, 2, …) AFTER incrementing,
* return the wall-clock time when a retry is allowed. Returns null once the
+10 -22
View File
@@ -24,9 +24,10 @@ import { isSafeAppPath } from "@/lib/notifications/tap-target";
* permanent reject (410/404) and the channel can no longer succeed.
* The 410 subscriptions themselves are deleted from the DB regardless,
* so a hard-channel-reject means the user has zero devices left.
* - `hardReject: false` for any 5xx, 429, fetch failure, or
* "no subscriptions at all" (treated as soft so the user can re-pair
* a device without the channel staying auto-disabled forever).
* - `hardReject: false` for any 5xx, 429 or fetch failure.
* - `reason: "web_push_no_subscriptions"` (or `_vapid_not_configured`) when
* there is nothing to send to; the dispatcher leaves the channel state
* alone for those.
*/
export async function sendViaWebPush(
userId: string,
@@ -65,10 +66,8 @@ export async function sendViaWebPush(
});
if (!subscriptions.length) {
// No devices registered yet — not a hard channel reject. The user
// may simply not have hit "Subscribe" on this browser. Counting
// this as a give-up signal would lock the user out of the channel
// they just configured. Treat it as a soft "no recipient" instead.
// No browser subscribed (never, or push was turned off on the last
// one). Nothing failed; the dispatcher books this as a skip.
recordPushAttemptForPayload(payload, userId, {
userId,
channel: "WEB_PUSH",
@@ -83,24 +82,14 @@ export async function sendViaWebPush(
};
}
// v1.18.4 — a dispatcher payload may carry a stable per-slot tag in
// `metadata.webPushTag` (e.g. a medication dose slot, see
// `medicationDoseTag`). The matching `type:"clear"` push reuses the SAME
// tag so the SW can replace/close the pending reminder when the dose is
// logged — the PWA-only equivalent of ending a Live Activity. Discreet
// mode still wins so no event name leaks even when a stable tag exists.
// A payload may carry a stable per-slot tag in `metadata.webPushTag`
// (a medication dose slot, see `medicationDoseTag`), so a repeated
// reminder for the same dose replaces the one on screen instead of
// stacking. Discreet mode still wins so no event name leaks.
const stableTag =
typeof payload.metadata?.webPushTag === "string"
? payload.metadata.webPushTag
: undefined;
// v1.18.4 — server-authoritative app-badge count. The dispatcher puts the
// user's current outstanding-dose count in `metadata.badgeCount`; the SW
// calls `navigator.setAppBadge(count)` (feature-detected, no-op elsewhere).
const badgeCount =
typeof payload.metadata?.badgeCount === "number" &&
Number.isFinite(payload.metadata.badgeCount)
? Math.max(0, Math.trunc(payload.metadata.badgeCount))
: undefined;
const pushPayload = JSON.stringify({
title: plainPushText(payload.title, payload.eventType),
@@ -110,7 +99,6 @@ export async function sendViaWebPush(
// but the discreet contract still requires no event name on the wire —
// mirror the APNs GENERIC_EVENT collapse (QA M-sec3).
tag: payload.discreet ? "REMINDER" : (stableTag ?? payload.eventType),
...(badgeCount !== undefined ? { badge: badgeCount } : {}),
// v1.15.20 — opt-in deep link: a dispatcher payload may carry a
// same-origin path in `metadata.url` (e.g. the Coach nudge's
// `/coach`); everything else keeps the legacy "/" click
-157
View File
@@ -1,157 +0,0 @@
/**
* v1.18.4 — Web Push "clear" for a logged medication dose.
*
* A self-hoster on the installed PWA with no Apple Developer account has no
* Live Activity / Home-Screen widget to reconcile when a dose is logged on
* another tab/device. The native path (`queueMedicationIntakeSync`) ends
* the Live Activity over APNs; this module is the PWA-only equivalent: it
* pushes a `{ type: "clear", tag }` payload to the user's Web Push
* subscriptions so the service worker closes the still-pending dose-due
* reminder for that exact slot (same stable tag the reminder used) and
* refreshes the app badge.
*
* Targeted + best-effort: it talks straight to the user's PushSubscription
* rows (not the full dispatcher cascade) because a clear has no Telegram /
* ntfy / email analogue — those channels can't retract a sent message. Every
* failure is swallowed + annotated; a clear miss never fails the intake
* mutation that triggered it. Expired (410/404) subscriptions are reaped, the
* same as the alert sender.
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
import { getEvent } from "@/lib/logging/context";
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch } from "@/lib/safe-fetch";
import { medicationDoseTag } from "@/lib/notifications/dose-tag";
export interface WebClearInput {
userId: string;
medicationId: string;
/** ISO-8601 scheduled-for instant of the affected dose slot. */
scheduledFor: string;
/**
* Server-authoritative count of the user's still-outstanding doses after
* this mutation. The SW reflects it via `navigator.setAppBadge`. Omit to
* leave the badge untouched.
*/
badgeCount?: number;
}
/**
* Send a `type:"clear"` web push that closes the pending reminder for one
* dose slot and updates the app badge. Best-effort, fire-and-forget safe.
*/
export async function dispatchMedicationIntakeWebClear(
input: WebClearInput,
): Promise<void> {
try {
const config = await getVapidConfig();
if (!config) return; // VAPID not configured — nothing to clear.
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: input.userId },
});
if (!subscriptions.length) return;
const webpush = await import("web-push");
webpush.setVapidDetails(
config.subject,
config.publicKey,
config.privateKey,
);
const badgeCount =
typeof input.badgeCount === "number" && Number.isFinite(input.badgeCount)
? Math.max(0, Math.trunc(input.badgeCount))
: undefined;
const clearPayload = JSON.stringify({
type: "clear",
tag: medicationDoseTag(input.medicationId, input.scheduledFor),
...(badgeCount !== undefined ? { badge: badgeCount } : {}),
});
const expiredIds: string[] = [];
for (const sub of subscriptions) {
try {
if (!isPublicUrl(sub.endpoint)) {
expiredIds.push(sub.id);
continue;
}
const p256dh = decrypt(sub.p256dh);
const auth = decrypt(sub.auth);
// Same transport swap as the main sender: sign + encrypt via
// `generateRequestDetails`, then dial through `safeFetch` with the
// pinned dispatcher so a DNS rebind between the literal
// `isPublicUrl` check above and the connect cannot reach a private
// address (issue #217).
const details = webpush.generateRequestDetails(
{ endpoint: sub.endpoint, keys: { p256dh, auth } },
clearPayload,
) as {
endpoint: string;
method: string;
headers: Record<string, string>;
body: Buffer | string | null;
};
const clearRes = await safeFetch(
details.endpoint,
{
method: details.method,
headers: details.headers,
body: (details.body ?? undefined) as BodyInit | undefined,
},
{ requirePublicHost: true },
);
if (clearRes.status < 200 || clearRes.status >= 300) {
throw Object.assign(
new Error(`Web Push clear responded ${clearRes.status}`),
{ statusCode: clearRes.status },
);
}
} catch (err: unknown) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 410 || status === 404) expiredIds.push(sub.id);
// Soft errors on a best-effort clear are ignored — the next reminder
// tick or badge refresh self-corrects.
}
}
if (expiredIds.length > 0) {
await prisma.pushSubscription
.deleteMany({ where: { id: { in: expiredIds } } })
.catch(() => {
/* best-effort cleanup */
});
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addWarning(`medication_intake_web_clear_failed: ${message}`);
}
}
/**
* Bulk entry point — de-duplicates affected slots so one clear fires per
* distinct `(medicationId, scheduledFor)` slot a batch touched. The badge
* count is the same post-mutation outstanding total for every slot, so it is
* passed through unchanged.
*/
export async function dispatchMedicationIntakeWebClearBulk(args: {
userId: string;
slots: Array<{ medicationId: string; scheduledFor: string }>;
badgeCount?: number;
}): Promise<void> {
const seen = new Set<string>();
for (const slot of args.slots) {
const key = `${slot.medicationId}|${slot.scheduledFor}`;
if (seen.has(key)) continue;
seen.add(key);
await dispatchMedicationIntakeWebClear({
userId: args.userId,
medicationId: slot.medicationId,
scheduledFor: slot.scheduledFor,
badgeCount: args.badgeCount,
});
}
}
@@ -0,0 +1,82 @@
import { apiFetchRaw } from "@/lib/api/api-fetch";
/** This browser's push subscription, or null when it has none. */
async function browserSubscription(): Promise<PushSubscription | null> {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
return null;
}
const registration = await navigator.serviceWorker.getRegistration("/sw.js");
return (await registration?.pushManager.getSubscription()) ?? null;
}
/**
* Bounded: on a connection that is up but carries nothing, sign-out would
* otherwise wait out the browser's own timeout before it even starts.
*/
function deleteServerRow(subscription: PushSubscription): Promise<Response> {
return apiFetchRaw("/api/notifications/web-push", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
signal: AbortSignal.timeout(5_000),
});
}
/**
* Store this browser's subscription for the signed-in account. Also run for a
* subscription the browser already holds, so a row the server lost comes
* back and a channel switched off by failed sends is turned on again.
*/
export function saveWebPushSubscription(
subscription: PushSubscription,
): Promise<Response> {
const subJson = subscription.toJSON();
return apiFetchRaw("/api/notifications/web-push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: {
p256dh: subJson.keys?.p256dh ?? "",
auth: subJson.keys?.auth ?? "",
},
}),
});
}
/**
* "Disable push": remove the server row, then the browser's own
* subscription. Does nothing when the browser has none.
*/
export async function unsubscribeWebPush(): Promise<void> {
const subscription = await browserSubscription();
if (!subscription) return;
await deleteServerRow(subscription);
await subscription.unsubscribe();
}
/**
* Run a sign-out with this browser's push subscription taken off the account.
* A subscription left behind keeps showing that account's reminders on this
* browser, to whoever uses it next.
*
* The server row goes first, while the session can still name it; the
* browser's subscription only once `signOut` resolved. A refused sign-out
* stores the row again, so the person who is still signed in keeps push.
* Failures around the subscription never block the sign-out.
*/
export async function signOutWithoutWebPush(
signOut: () => Promise<unknown>,
): Promise<void> {
const subscription = await browserSubscription().catch(() => null);
if (subscription) await deleteServerRow(subscription).catch(() => null);
try {
await signOut();
} catch (failure) {
if (subscription) {
await saveWebPushSubscription(subscription).catch(() => null);
}
throw failure;
}
await subscription?.unsubscribe().catch(() => false);
}
+2
View File
@@ -93,6 +93,8 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"auth.refresh.invalid",
"auth.refresh.reuse",
"auth.refresh.revoked",
"auth.register.invite_invalid",
"auth.register.taken",
"auth.reproof.failed",
"auth.reproof.required",
"auth.reproof.too_weak",
+2 -2
View File
@@ -2301,12 +2301,12 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"403": {
description:
"Refused before any work. Either the operator runs `OIDC_ONLY=true` (`meta.errorCode` = `oidc_only`), or self-registration is closed and the request carried no invite, or the invite it carried was invalid, expired or exhausted. The three are distinguishable by prose but not by code.",
"Refused before any work. Either the operator runs `OIDC_ONLY=true` (`meta.errorCode` = `oidc_only`), or self-registration is closed and the request carried no invite, or the invite it carried was invalid, expired or exhausted (`auth.register.invite_invalid`).",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"The email or the username is taken. Deliberately one message for both, so this is not an account-existence oracle. Also the answer when two concurrent signups race past the pre-check and the database unique index catches the second.",
"The email or the username is taken (`auth.register.taken`). Deliberately one message and one code for both, so this is not an account-existence oracle. Also the answer when two concurrent signups race past the pre-check and the database unique index catches the second. `auth.already_authenticated` when the request carries a signed-in session.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
@@ -66,34 +66,22 @@ const apnsTestResponse = z
const webPushTestResponse = z
.object({
ok: z.boolean(),
sent: z
.number()
.int()
.describe("1 on success, 0 on failure. Never more than one."),
ok: z
.boolean()
.describe(
"Whether at least one of the account's subscriptions accepted the push.",
),
latencyMs: z
.number()
.int()
.optional()
.describe("Round-trip to the push service. Present on success only."),
perEndpoint: z
.array(
z.object({
host: z
.string()
.describe(
"Host of the push service the test went to — the endpoint path is a routing secret and is never returned.",
),
status: z
.number()
.int()
.nullable()
.describe(
"The push service's HTTP status, or null when the attempt threw before one existed.",
),
}),
)
.describe("One entry: only the newest subscription is tested."),
.describe("Time to send to every subscription. Present on success only."),
reason: z
.string()
.optional()
.describe(
"Why it was not, when `ok` is false. Absent on success. The sender's own classification, the same code a failed reminder records (`web_push_410_gone` when every endpoint is gone, a status-derived code otherwise).",
),
})
.meta({ id: "WebPushTestResponse" });
@@ -200,10 +188,10 @@ export const notificationTransportPaths: NonNullable<
"/api/notifications/web-push/test": {
post: {
tags: ["Notifications"],
summary: "Send a test push to the newest Web Push subscription",
summary: "Send a test push to every Web Push subscription",
description:
"Fires one notification to the caller's MOST RECENT subscription only — a person with a phone, a tablet and a desktop registered should not get three buzzes per click. Takes no body. Rate-limited 5 per minute per user.\n\n" +
"Read `ok`, not the status. A push that the service rejects still answers 200, with `ok: false` and the service's own status in `perEndpoint`. Only the endpoint's HOST is reported: the full endpoint is the routing secret for that subscription, and push services embed it in their own error messages, so it is stripped before anything is logged or returned.",
"Fires one notification to each of the caller's subscriptions, through the same sender real reminders use, with the text in the account's language. The device the test was pressed on gets it whichever subscription is newest. Endpoints the push service reports gone (404/410) are removed, as on a real send. Takes no body. Rate-limited 5 per minute per user.\n\n" +
"Read `ok`, not the status. A push that every service rejects still answers 200, with `ok: false`, a `reason`, and `meta.errorCode`: `upstream_rejected`, or `connection_failed` when no push service answered. No endpoint is returned: the full endpoint is the routing secret for that subscription.",
responses: {
"200": {
description:
+1 -1
View File
@@ -105,7 +105,7 @@ const channelDeliveryStatus = z
state: z
.enum(["active", "auto_disabled", "manually_disabled", "sending_paused"])
.describe(
"Server-derived. `auto_disabled` — the dispatcher turned it off after repeated hard rejects (`disabledReason` says why). `manually_disabled` — the user turned it off. `sending_paused` — still enabled but inside a retry cooldown until `nextRetryAt`. `active` — enabled and sending.",
"Server-derived. `auto_disabled` — the dispatcher turned it off after repeated hard rejects (`disabledReason` says why). `manually_disabled` — the user turned it off. `sending_paused` — still enabled but inside a retry cooldown until `nextRetryAt`, after a transient failure or after a send that had nobody to go to (no browser subscribed, no iPhone paired, a transport the operator has not set up; `lastFailureReason` names it, `consecutiveFailures` is not raised). `active` — enabled and sending.",
),
disabledReason: z
.string()
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { isOfflineNavigationClick } from "@/lib/pwa/offline-navigation";
const HERE = {
origin: "https://app.example",
pathname: "/medications",
search: "",
};
interface AnchorInit {
href: string;
target?: string;
download?: boolean;
}
/** A click on an element inside an anchor, the way a nav icon is tapped. */
function clickOn(
anchor: AnchorInit | null,
overrides: Partial<Parameters<typeof isOfflineNavigationClick>[0]> = {},
) {
const link = anchor && {
href: new URL(anchor.href, HERE.origin).href,
target: anchor.target ?? "",
hasAttribute: (name: string) => name === "download" && !!anchor.download,
};
return {
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
target: {
closest: (selector: string) => (selector === "a[href]" ? link : null),
} as unknown as EventTarget,
...overrides,
};
}
describe("isOfflineNavigationClick", () => {
it("holds a tap on a link to another page while offline", () => {
expect(isOfflineNavigationClick(clickOn({ href: "/" }), HERE, false)).toBe(
true,
);
expect(
isOfflineNavigationClick(
clickOn({ href: "/measurements?add=WEIGHT" }),
HERE,
false,
),
).toBe(true);
// Same path, different query: a different page for the router.
expect(
isOfflineNavigationClick(
clickOn({ href: "/medications?tab=history" }),
HERE,
false,
),
).toBe(true);
});
it("lets every link through while online", () => {
expect(isOfflineNavigationClick(clickOn({ href: "/" }), HERE, true)).toBe(
false,
);
});
it("leaves links within the open page alone", () => {
expect(
isOfflineNavigationClick(
clickOn({ href: "/medications#main-content" }),
HERE,
false,
),
).toBe(false);
});
it("leaves clicks the browser handles itself alone", () => {
const offline = (click: ReturnType<typeof clickOn>) =>
isOfflineNavigationClick(click, HERE, false);
expect(offline(clickOn({ href: "https://elsewhere.example/" }))).toBe(
false,
);
expect(offline(clickOn({ href: "/", target: "_blank" }))).toBe(false);
expect(offline(clickOn({ href: "/export.csv", download: true }))).toBe(
false,
);
expect(offline(clickOn({ href: "/" }, { metaKey: true }))).toBe(false);
expect(offline(clickOn({ href: "/" }, { button: 1 }))).toBe(false);
expect(offline(clickOn({ href: "/" }, { defaultPrevented: true }))).toBe(
false,
);
expect(offline(clickOn(null))).toBe(false);
});
});
@@ -133,8 +133,13 @@ afterEach(() => {
}
});
/** A client signed in as the caller: the persister binds to that account. */
function appClient(): QueryClient {
return new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS });
const client = new QueryClient({
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
});
client.setQueryData(queryKeys.authMe(), { id: "acct-delegate" });
return client;
}
describe("persisting while inside another record", () => {
@@ -207,7 +212,13 @@ describe("restoring while inside another record", () => {
// hash already makes a snapshot written in one record unreadable in
// another; asserting only that would be a check that cannot fail for the
// control it names. This one counts what landed in the cache, so removing
// the guard leaves exactly one entry here and turns it red.
expect(reader.getQueryCache().getAll()).toHaveLength(0);
// the guard leaves exactly one entry here and turns it red. The only
// entry is the reader's own `/api/auth/me`.
expect(
reader
.getQueryCache()
.getAll()
.map((q) => q.queryKey[0]),
).toEqual(["auth"]);
});
});
+134 -2
View File
@@ -98,7 +98,7 @@ describe("session-end CacheStorage cleanup", () => {
}
});
it("deletes current and legacy HealthLog static/page/data caches only", async () => {
it("deletes current and legacy HealthLog page and data caches only", async () => {
const cacheNames = [
"healthlog-static-v1.34.0",
"healthlog-static-v1.15.20",
@@ -124,9 +124,11 @@ describe("session-end CacheStorage cleanup", () => {
.soft(deleted.sort())
.toEqual(
cacheNames
.filter((name) => /^healthlog-(?:static|pages|data)-/.test(name))
.filter((name) => /^healthlog-(?:pages|data)-/.test(name))
.sort(),
);
// The static cache holds catalogs and icons, nothing of an account.
expect(deleted).not.toContain("healthlog-static-v1.34.0");
expect(deleted).not.toContain("unrelated-app-static-v7");
expect(deleted).not.toContain("workbox-precache-healthlog");
});
@@ -193,6 +195,7 @@ describe("persister round-trips through a fake IndexedDB", () => {
};
const source = new QueryClient();
source.setQueryData(["auth", "me"], { id: "user-a" });
source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 });
const stop = startPersistingQueryCache(source, "v1.18.6");
// Force a flush by advancing past the 1s debounce. The trigger write uses
@@ -205,6 +208,7 @@ describe("persister round-trips through a fake IndexedDB", () => {
stop();
const matchingBuild = new QueryClient();
matchingBuild.setQueryData(["auth", "me"], { id: "user-a" });
await restorePersistedQueryCache(matchingBuild, "v1.18.6");
expect(matchingBuild.getQueryData(["dashboard", "snapshot"])).toEqual({
weightKg: 81,
@@ -220,6 +224,7 @@ describe("persister round-trips through a fake IndexedDB", () => {
expect(restored?.isStale()).toBe(true);
const foreignBuild = new QueryClient();
foreignBuild.setQueryData(["auth", "me"], { id: "user-a" });
await restorePersistedQueryCache(foreignBuild, "v9.9.9");
expect(
foreignBuild.getQueryData(["dashboard", "snapshot"]),
@@ -278,6 +283,7 @@ describe("persister round-trips through a fake IndexedDB", () => {
// Persist a STALE (empty) chart series under an allowlisted family.
const source = new QueryClient();
source.setQueryData(["auth", "me"], { id: "user-a" });
source.setQueryData(["chart-data", "WEIGHT"], []);
const stop = startPersistingQueryCache(source, "v1.18.6");
vi.useFakeTimers();
@@ -289,6 +295,7 @@ describe("persister round-trips through a fake IndexedDB", () => {
// The live client already fetched the FRESH (post-mutation) series.
const live = new QueryClient();
live.setQueryData(["auth", "me"], { id: "user-a" });
live.setQueryData(["chart-data", "WEIGHT"], [{ id: "fresh-row" }]);
await restorePersistedQueryCache(live, "v1.18.6");
@@ -297,6 +304,15 @@ describe("persister round-trips through a fake IndexedDB", () => {
expect(live.getQueryData(["chart-data", "WEIGHT"])).toEqual([
{ id: "fresh-row" },
]);
// A query the page has started but not answered has no data: filled.
const mounting = new QueryClient();
mounting.setQueryData(["auth", "me"], { id: "user-a" });
mounting
.getQueryCache()
.build(mounting, { queryKey: ["chart-data", "WEIGHT"] });
await restorePersistedQueryCache(mounting, "v1.18.6");
expect(mounting.getQueryData(["chart-data", "WEIGHT"])).toEqual([]);
});
it("discards a snapshot stamped with a different account id", async () => {
@@ -370,8 +386,124 @@ describe("persister round-trips through a fake IndexedDB", () => {
// restore can't resurrect it either.
expect(store.get("react-query")).toBeUndefined();
});
it("restores nothing until the live account is known", async () => {
installRoundTripIdb();
const source = new QueryClient();
source.setQueryData(["auth", "me"], { id: "user-a" });
await persistOnce(source);
// A cold start: the snapshot is on disk, `/api/auth/me` has not answered.
const booting = new QueryClient();
await restorePersistedQueryCache(booting, "v1.18.6");
expect(booting.getQueryData(["dashboard", "snapshot"])).toBeUndefined();
// Not discarded either: the account may well turn out to be its owner.
expect(roundTripStore.get("react-query")).toBeDefined();
booting.setQueryData(["auth", "me"], { id: "user-a" });
await restorePersistedQueryCache(booting, "v1.18.6");
expect(booting.getQueryData(["dashboard", "snapshot"])).toEqual({
weightKg: 81,
});
});
it("writes the dashboard snapshot without the model-written briefing", async () => {
installRoundTripIdb();
const source = new QueryClient();
source.setQueryData(["auth", "me"], { id: "user-a" });
const briefing = { headline: "Your pressure ran high this week." };
const briefingMemory = { recall: "Last week: steady.", forward: "Calm." };
source.setQueryData(["dashboard", "snapshot", "en"], {
weightKg: 81,
briefing,
briefingMemory,
briefingState: "ready",
});
await persistOnce(source);
const written = JSON.stringify(roundTripStore.get("react-query"));
expect(written).toContain('"weightKg":81');
expect(written).not.toContain(briefing.headline);
expect(written).not.toContain(briefingMemory.recall);
// The live cache keeps what the server sent.
expect(
source.getQueryData<{ briefing: unknown }>([
"dashboard",
"snapshot",
"en",
])?.briefing,
).toBe(briefing);
});
it("discards a snapshot written before any account was known", async () => {
installRoundTripIdb();
await persistOnce(new QueryClient());
const live = new QueryClient();
live.setQueryData(["auth", "me"], { id: "user-b" });
await restorePersistedQueryCache(live, "v1.18.6");
expect(live.getQueryData(["dashboard", "snapshot"])).toBeUndefined();
expect(roundTripStore.get("react-query")).toBeUndefined();
});
});
// ── shared fake IndexedDB for the account cases above ───────────────────────
const roundTripStore = new Map<string, unknown>();
function installRoundTripIdb(): void {
roundTripStore.clear();
const makeReq = (result?: unknown) => {
const req: Record<string, unknown> = { result };
queueMicrotask(() => (req.onsuccess as (() => void) | undefined)?.());
return req;
};
const fakeDb = {
close() {},
transaction() {
return {
objectStore() {
return {
put(value: unknown, key: string) {
roundTripStore.set(key, value);
return makeReq();
},
get(key: string) {
return makeReq(roundTripStore.get(key));
},
delete(key: string) {
roundTripStore.delete(key);
return makeReq();
},
};
},
set oncomplete(fn: () => void) {
queueMicrotask(fn);
},
set onerror(_fn: () => void) {},
};
},
};
(globalThis as { indexedDB?: unknown }).indexedDB = {
open() {
const req: Record<string, unknown> = { result: fakeDb };
queueMicrotask(() => (req.onsuccess as () => void)?.());
return req;
},
};
}
/** Write one dashboard snapshot from `source` through the 1 s debounce. */
async function persistOnce(source: QueryClient): Promise<void> {
source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 });
const stop = startPersistingQueryCache(source, "v1.18.6");
vi.useFakeTimers();
source.setQueryData(["chart-data", "WEIGHT"], [{ v: 81 }]);
vi.advanceTimersByTime(1100);
vi.useRealTimers();
await new Promise((r) => setTimeout(r, 0));
stop();
}
describe("persister is bounded and surfaces quota failures", () => {
const original = (globalThis as { indexedDB?: unknown }).indexedDB;
@@ -1,149 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
isAllowlistedApiRead,
isCacheableApiResponse,
} from "@/lib/pwa/sw-cache-policy";
const SW_SOURCE = readFileSync(join(process.cwd(), "public", "sw.js"), "utf8");
describe("isAllowlistedApiRead — offline read boundary", () => {
it("caches the curated safe GET read endpoints", () => {
expect(isAllowlistedApiRead("/api/dashboard/snapshot")).toBe(true);
expect(isAllowlistedApiRead("/api/dashboard/widgets")).toBe(true);
// v1.37.20 — the Today rail's offline read. Watched red before the
// allowlist entry landed.
expect(isAllowlistedApiRead("/api/daily/digest")).toBe(true);
expect(isAllowlistedApiRead("/api/measurements")).toBe(true);
expect(
isAllowlistedApiRead("/api/measurements/series-batch?types=WEIGHT"),
).toBe(true);
expect(isAllowlistedApiRead("/api/medications")).toBe(true);
expect(isAllowlistedApiRead("/api/version")).toBe(true);
});
// v1.18.6 — AI/clinical narrative surfaces are never disk-cached. `/api/insights`
// previously prefix-matched the Coach chat list (`/api/insights/chat`, which
// returns decrypted conversation content) into the data cache; `/api/analytics`
// carries clinical aggregates. Both are off the allowlist.
it("never caches the AI / clinical narrative surfaces", () => {
expect(isAllowlistedApiRead("/api/insights")).toBe(false);
expect(isAllowlistedApiRead("/api/insights/chat")).toBe(false);
expect(isAllowlistedApiRead("/api/insights/generate")).toBe(false);
expect(isAllowlistedApiRead("/api/analytics")).toBe(false);
expect(isAllowlistedApiRead("/api/analytics/range")).toBe(false);
});
it("never caches auth / session / token / login surfaces", () => {
expect(isAllowlistedApiRead("/api/auth/me")).toBe(false);
expect(isAllowlistedApiRead("/api/auth/logout")).toBe(false);
expect(isAllowlistedApiRead("/api/sessions")).toBe(false);
expect(isAllowlistedApiRead("/api/tokens")).toBe(false);
expect(isAllowlistedApiRead("/api/login")).toBe(false);
expect(isAllowlistedApiRead("/api/webauthn/options")).toBe(false);
});
it("refuses an auth/token segment nested under an allowlisted prefix", () => {
expect(isAllowlistedApiRead("/api/medications/tokens")).toBe(false);
expect(isAllowlistedApiRead("/api/measurements/auth")).toBe(false);
});
it("does not cache endpoints outside the allowlist", () => {
expect(isAllowlistedApiRead("/api/admin/users")).toBe(false);
expect(isAllowlistedApiRead("/api/devices")).toBe(false);
expect(isAllowlistedApiRead("/api/withings/callback")).toBe(false);
});
it("never caches the cycle calendar feed or its management routes (#1350)", () => {
// The feed URL is a credential and its body is a record's cycle dates;
// neither belongs in a browser's durable storage.
const feed = `/api/cycle/feed/hlf_${"1f".repeat(24)}.ics`;
expect(isAllowlistedApiRead(feed)).toBe(false);
expect(isAllowlistedApiRead("/api/cycle/feed-subscription")).toBe(false);
expect(SW_SOURCE).not.toContain("/api/cycle");
});
it("does not match a prefix that is only a partial path segment", () => {
// `/api/measurements-export` must NOT match `/api/measurements`.
expect(isAllowlistedApiRead("/api/measurements-export")).toBe(false);
});
});
describe("isCacheableApiResponse — body/header refusal", () => {
it("caches a successful read with a plain body", () => {
expect(
isCacheableApiResponse({
ok: true,
cacheControl: "private, max-age=0",
bodyText: JSON.stringify({ data: { weightKg: 80 }, error: null }),
}),
).toBe(true);
});
it("refuses non-ok responses", () => {
expect(isCacheableApiResponse({ ok: false, bodyText: "{}" })).toBe(false);
});
it("refuses a no-store response", () => {
expect(
isCacheableApiResponse({
ok: true,
cacheControl: "no-store",
bodyText: "{}",
}),
).toBe(false);
});
it("refuses secret-shaped bodies (hlk_ / hlr_ / sk-)", () => {
expect(
isCacheableApiResponse({
ok: true,
bodyText: JSON.stringify({ data: { token: "hlk_abc123def" } }),
}),
).toBe(false);
expect(
isCacheableApiResponse({
ok: true,
bodyText: JSON.stringify({ data: { refresh: "hlr_xyz789ghi" } }),
}),
).toBe(false);
expect(
isCacheableApiResponse({
ok: true,
bodyText: JSON.stringify({ data: { key: "sk-livesecret00" } }),
}),
).toBe(false);
});
});
// v1.18.6.x regression guard. The first v1.18.6 cut cached allowlisted API
// reads with stale-while-revalidate, which served the pre-mutation copy first
// and broke read-after-write (a list fetched straight after a create showed
// the old/empty data). The strategy must be network-first: always fetch fresh
// online, fall back to cache ONLY when the network throws (offline).
describe("public/sw.js — allowlisted API reads are network-first", () => {
it("routes the allowlisted API branch through a network-first handler", () => {
expect(SW_SOURCE).toContain("networkFirstApi(request)");
expect(SW_SOURCE).toContain("async function networkFirstApi(request)");
});
it("no longer uses stale-while-revalidate for API reads", () => {
expect(SW_SOURCE).not.toContain("staleWhileRevalidateApi");
});
it("falls back to the cached copy only inside the network-failure catch", () => {
const handler = SW_SOURCE.slice(
SW_SOURCE.indexOf("async function networkFirstApi(request)"),
);
const tryIdx = handler.indexOf("await fetch(request)");
const catchIdx = handler.indexOf("} catch {");
const cacheMatchIdx = handler.indexOf("cache.match(request)");
// fetch comes first; the cache read lives after the catch boundary.
expect(tryIdx).toBeGreaterThan(-1);
expect(catchIdx).toBeGreaterThan(tryIdx);
expect(cacheMatchIdx).toBeGreaterThan(catchIdx);
});
});
+12
View File
@@ -0,0 +1,12 @@
import { settleWithin } from "@/lib/async/settle-within";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
/**
* The session-end wipe before the page leaves for the identity provider,
* where the next session starts. The wait is capped: WebKit can leave an
* IndexedDB open hanging after the installed app returns from the
* background, and the button must still work then.
*/
export function clearOfflineCachesBeforeLeaving(): Promise<void> {
return settleWithin(clearOfflineCachesForSessionEnd(), 1_500, undefined);
}
+46
View File
@@ -0,0 +1,46 @@
type ClickLike = Pick<
MouseEvent,
| "defaultPrevented"
| "button"
| "metaKey"
| "ctrlKey"
| "shiftKey"
| "altKey"
| "target"
>;
/**
* Whether a click would leave the open page for another one of this app while
* the browser is offline.
*
* Signed-in pages are never cached (the proxy sends them `no-store`), so that
* navigation can only end on the service worker's offline page and throws
* away a page that was still readable. Clicks the browser handles on its own
* (new tab, download, another origin) and links within the same page are left
* alone.
*/
export function isOfflineNavigationClick(
event: ClickLike,
here: Pick<Location, "origin" | "pathname" | "search">,
online: boolean,
): boolean {
if (online || event.defaultPrevented || event.button !== 0) return false;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return false;
}
const anchor = (event.target as Partial<Element> | null)?.closest?.(
"a[href]",
) as HTMLAnchorElement | null | undefined;
// An SVG `<a>` carries an animated string, not a URL.
if (!anchor || typeof anchor.href !== "string") return false;
if (anchor.target && anchor.target !== "_self") return false;
if (anchor.hasAttribute("download")) return false;
let url: URL;
try {
url = new URL(anchor.href, here.origin);
} catch {
return false;
}
if (url.origin !== here.origin) return false;
return url.pathname !== here.pathname || url.search !== here.search;
}
+75 -61
View File
@@ -1,10 +1,10 @@
/**
* Minimal IndexedDB-backed TanStack Query persistence (v1.18.6).
* Minimal IndexedDB-backed TanStack Query persistence.
*
* No external persister dependency: a tiny IDB key/value store plus a manual
* dehydrate-on-cache-change / hydrate-on-boot bridge. The goal is offline
* READ value — an installed PWA opened offline hydrates the last cached
* dashboard/series instantly instead of painting empty skeletons forever.
* dehydrate-on-cache-change / hydrate-after-sign-in bridge. It is a warm
* start for the dashboard reads, not an offline mode: signed-in pages are
* never cached, so the app cannot boot without a connection.
*
* Safety:
* - Only successful (`status: "success"`) query results are persisted.
@@ -12,15 +12,18 @@
* dashboard-offline families off disk. This is the privacy floor: a
* denylist would persist every family it didn't name (cycle, mood, illness,
* labs, coach, insights, medications, …) in plaintext. Only the dashboard
* snapshot, its resolved widget layout, and the measurement daily-series —
* the data the dashboard needs to paint last-known values offline — are
* eligible. Clinical / narrative families are never written to disk.
* snapshot, its resolved widget layout, and the measurement daily-series
* are eligible. Clinical / narrative families are never written to disk.
* - Model-written text never is either: the snapshot carries the daily
* briefing and its recall line, and both are nulled before the write
* (`withoutModelText`).
* - `clearPersistedQueryCache()` is called on every session END (logout AND
* the 401 / session-expiry redirect), so a shared device never leaks one
* account's cached data to the next once the session is gone.
* - The payload is stamped with the current user id; a restore whose stored
* marker doesn't match the live account discards + clears the cache rather
* than hydrating a foreign account's data.
* - The payload is stamped with the current user id. A restore runs only
* once `["auth","me"]` names the live account, and a stored owner that is
* not that account discards + clears the cache rather than hydrating a
* foreign account's data.
* - Respects the centralised queryKey factory: it reads existing query keys,
* it never invents new ones.
*/
@@ -35,10 +38,7 @@ import {
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { queryKeys } from "@/lib/query-keys";
import { isReadingSharedRecord } from "@/lib/query-keys/record-scope";
// Session-end cleanup removes every current/legacy HealthLog cache family
// while leaving unrelated CacheStorage namespaces on the same origin
// untouched; the name contract is shared with `public/sw.js`.
import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy";
import { ACCOUNT_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy";
const DB_NAME = "healthlog-query-cache";
const STORE = "kv";
@@ -59,8 +59,7 @@ const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB
/**
* Strict allowlist of the only query families written to disk — the
* low-sensitivity reads the dashboard needs to paint last-known values when
* opened offline. Everything else (coach, insights, illness, labs, mood,
* low-sensitivity reads the dashboard paints first. Everything else (coach, insights, illness, labs, mood,
* medications, cycle, auth, admin, tokens, …) stays in memory only.
*
* - `["dashboard", …]` — the unified first-paint snapshot
@@ -82,6 +81,29 @@ const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB
*/
const PERSIST_ALLOWLIST_HEADS = ["dashboard", "chart-data"] as const;
/** Snapshot fields a model may have written: the daily briefing and its recall line. */
const MODEL_TEXT_FIELDS = ["briefing", "briefingMemory"] as const;
/**
* The dashboard snapshot with its model-written fields nulled. The web
* dashboard reads the briefing from the Today digest, which is never
* persisted, so the restored snapshot loses nothing it paints. Returns new
* objects; the live cache keeps its copy.
*/
export function withoutModelText(
query: DehydratedState["queries"][number],
): DehydratedState["queries"][number] {
const data = query.state.data;
if (query.queryKey[0] !== "dashboard" || !data || typeof data !== "object") {
return query;
}
const stripped: Record<string, unknown> = { ...data };
for (const field of MODEL_TEXT_FIELDS) {
if (field in stripped) stripped[field] = null;
}
return { ...query, state: { ...query.state, data: stripped } };
}
export function isPersistableKey(queryKey: readonly unknown[]): boolean {
const head = queryKey[0];
if (typeof head !== "string") return false;
@@ -166,9 +188,9 @@ function currentUserId(queryClient: QueryClient): string | null {
}
/**
* Restore persisted query data into the client (called once before the first
* paint of the authenticated shell). A build-version or age mismatch discards
* the snapshot rather than hydrating a stale/foreign-schema cache.
* Restore persisted query data into the client once the signed-in account is
* known. A build-version, age or owner mismatch discards the snapshot rather
* than hydrating a stale, foreign-schema or foreign-account cache.
*/
export async function restorePersistedQueryCache(
queryClient: QueryClient,
@@ -182,6 +204,9 @@ export async function restorePersistedQueryCache(
// saying "viewing her record" is the mis-context failure this feature is
// built to prevent, in the one place a full reload cannot clear.
if (isReadingSharedRecord()) return;
// Without a live account there is nothing to compare the owner with.
const liveUserId = currentUserId(queryClient);
if (liveUserId === null) return;
try {
const payload = await idbGet<PersistedPayload>();
if (!payload) return;
@@ -193,32 +218,24 @@ export async function restorePersistedQueryCache(
await clearPersistedQueryCache();
return;
}
// Per-account binding: if the live account is known and differs from the
// snapshot's stored owner, this is a different user on the same browser
// profile — discard + clear rather than hydrate a foreign account's data.
// When the live id is not yet known (auth/me not resolved at restore time)
// we don't gate on it; the session-end wipe is the primary guarantee and
// the build-version + 24 h age gates still apply.
const liveUserId = currentUserId(queryClient);
if (liveUserId !== null && payload.userId !== liveUserId) {
// Per-account binding: a snapshot written by anyone but the live account
// (or before any account was known) is another user's on the same
// browser profile. Discard and clear it.
if (payload.userId !== liveUserId) {
await clearPersistedQueryCache();
return;
}
// Never clobber a query the live client already holds. `hydrate` calls
// `setData` per dehydrated query unconditionally, so a snapshot restored
// in an effect AFTER the first paint would overwrite a query the page
// already fetched/prefetched — replacing the fresh (post-mutation) list
// with the stale persisted (often empty) copy. That was the read-after-
// write break across dashboard/measurements/medications: the restore won
// the race against the in-flight fetch. Drop every dehydrated query whose
// key the cache already carries so restore only ever FILLS gaps, never
// overwrites; a query already present is by definition fresher than disk.
// Only fill gaps, never overwrite data the live client holds: the
// restore lands after the shell has mounted the page, so a query may
// already carry a fresh (post-mutation) answer, and that is fresher than
// disk. A query the page has started but not answered yet has no data
// and is filled; its own fetch replaces the copy when it lands.
const cache = queryClient.getQueryCache();
const filtered: DehydratedState = {
...payload.state,
queries: payload.state.queries.filter(
(q) => cache.get(q.queryHash) === undefined,
(q) => cache.get(q.queryHash)?.state.data === undefined,
),
};
if (filtered.queries.length === 0 || !canHydrate()) return;
@@ -230,9 +247,8 @@ export async function restorePersistedQueryCache(
// after a create/update then serves the stale pre-mutation copy. Marking
// every restored query invalidated makes it stale-but-shown: the persisted
// data still paints instantly, and the first mount triggers a background
// refetch. Online that lands fresh data within ms; offline the refetch
// fails and the hydrated copy stays on screen — the offline value holds.
// Scoped to the keys we just hydrated so we never disturb a live query.
// refetch that lands fresh data within ms. Scoped to the keys just
// hydrated so a live query is never disturbed.
for (const dq of filtered.queries) {
cache.get(dq.queryHash)?.invalidate();
}
@@ -261,10 +277,14 @@ export function startPersistingQueryCache(
// the flush (rather than filtering keys) also means the last good snapshot
// of the caller's own dashboard survives a switch untouched.
if (isReadingSharedRecord()) return;
const state = dehydrate(queryClient, {
const dehydrated = dehydrate(queryClient, {
shouldDehydrateQuery: (query) =>
query.state.status === "success" && isPersistableKey(query.queryKey),
});
const state: DehydratedState = {
...dehydrated,
queries: dehydrated.queries.map(withoutModelText),
};
const payload: PersistedPayload = {
buildVersion,
savedAt: Date.now(),
@@ -339,10 +359,12 @@ export async function clearPersistedQueryCache(): Promise<void> {
}
/**
* Wipe every client-side cache that can hold one account's health data: the
* IndexedDB query snapshot, the SW offline read-data cache, and the SW page
* cache (cached navigation HTML). The single call for a session END (logout or
* 401 / expiry redirect). Best-effort across the board.
* Wipe every client-side cache that can hold one account's data: the
* IndexedDB query snapshot, the worker's page cache and the API caches older
* releases left behind, current and legacy versions alike. The static cache
* (catalogs, icons) holds nothing of an account and stays, so a sign-in does
* not download the locale catalog again. The single call for a session END
* (logout or 401 / expiry redirect) and at sign-in. Best-effort.
*/
export async function clearOfflineCachesForSessionEnd(): Promise<void> {
await clearPersistedQueryCache();
@@ -351,7 +373,7 @@ export async function clearOfflineCachesForSessionEnd(): Promise<void> {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k))
.filter((k) => ACCOUNT_CACHE_NAME_RE.test(k))
.map((k) => caches.delete(k)),
);
} catch {
@@ -361,23 +383,15 @@ export async function clearOfflineCachesForSessionEnd(): Promise<void> {
}
/**
* v1.36.0 — the same wipe, before a record switch in either direction.
* The same wipe, before a record switch in either direction.
*
* Deliberately the same bytes as the session-end wipe rather than a narrower
* one, and deliberately named for the act that calls it.
* The full page reload the switch performs clears the in-memory cache; the
* IndexedDB snapshot survives it and holds the dashboard the browser was last
* looking at. Without this call the first paint after the switch could show
* the previous record's numbers under a banner naming the new one.
*
* The full page reload the switch performs clears the in-memory cache; these
* two layers are the ones that survive it. The IndexedDB snapshot holds the
* dashboard the browser was last looking at, and the service worker's
* `healthlog-data-*` cache answers `/api/…` reads from disk on a
* stale-while-revalidate branch — so without this call, the first paint after
* switching into a record can be the previous record's numbers, served by the
* worker, under a banner naming the new one. Nothing about the page's own code
* would be wrong; the answer would just come from somewhere the page does not
* control.
*
* Run it BEFORE the reload and await it. A wipe racing the navigation is a
* wipe that sometimes does not happen, and "sometimes" is the whole failure.
* Run it BEFORE the reload and await it: a wipe racing the navigation
* sometimes does not happen.
*/
export async function clearOfflineCachesForRecordSwitch(): Promise<void> {
await clearOfflineCachesForSessionEnd();
+7 -74
View File
@@ -1,82 +1,15 @@
/**
* Service-worker offline-read cache policy (v1.18.6).
*
* The runtime copy of this logic lives inline in `public/sw.js` (a service
* worker cannot import app modules at runtime). This module is the
* unit-tested source of truth for the safety boundary — the allowlist match,
* the auth/token deny guard, and the body-level secret refusal — so the SW's
* caching decisions are covered by `pnpm test`. Keep the two in lockstep when
* either changes.
*
* Boundary: ONLY idempotent GET reads that render the core views are cached.
* Auth/mutation/token surfaces are never cached, and a body carrying a
* secret-shaped token (`hlk_`/`hlr_`/`sk-`) is refused even if its path was
* allowlisted by mistake — mirroring the idempotency cache's refusal.
*/
/**
* The worker's own cache names: `healthlog-{static,pages,data}-<version>`.
* Everything the app deletes from CacheStorage (session end, the "new
* version" reload) matches against this and nothing else, so another app's
* caches on the same origin are left alone. Keep in lockstep with
* Everything the app deletes from CacheStorage matches one of the two
* patterns below and nothing else, so another app's caches on the same
* origin are left alone. `data` names the API cache older releases kept; it
* stays so those caches are still deleted. Same pattern as
* `HEALTHLOG_CACHE_NAME_RE` in `public/sw.js`.
*/
export const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/;
/**
* Path prefixes whose safe GET reads are eligible for the offline data cache.
*
* v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`)
* are excluded: `/api/insights` prefix-matched the Coach chat endpoint
* (`/api/insights/chat`) into the disk cache, and neither belongs on durable
* storage. Only the dashboard snapshot, the widget layout, the measurement
* reads (incl. the batched daily series), the medication reads, and the
* version probe are cached.
* The caches that can hold something of one account: cached pages and the
* old API answers. The session wipe deletes these and keeps the static cache.
*/
export const API_READ_ALLOWLIST = [
"/api/dashboard/snapshot",
"/api/dashboard/widgets",
// v1.37.20 — the daily digest read: the Today rail is the first thing an
// offline reopen renders, and the digest is an idempotent GET with no
// secret in the body. The deny guard and the body-level refusal still
// apply on top, like every entry here.
"/api/daily/digest",
"/api/measurements",
"/api/medications",
"/api/version",
] as const;
/** Auth/token surfaces refused even when nested under an allowlisted prefix. */
export const API_DENY_RE =
/\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i;
/** Secret-shaped token patterns refused at the response-body level. */
export const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/;
export function isAllowlistedApiRead(pathname: string): boolean {
if (API_DENY_RE.test(pathname)) return false;
return API_READ_ALLOWLIST.some(
(p) =>
pathname === p ||
pathname.startsWith(p + "/") ||
pathname.startsWith(p + "?"),
);
}
/**
* Decide whether an allowlisted response may be persisted. `ok` + no
* `no-store` + no secret-shaped body. Mirrors `isCacheableApiResponse` in
* `public/sw.js`.
*/
export function isCacheableApiResponse(opts: {
ok: boolean;
cacheControl?: string | null;
bodyText?: string;
}): boolean {
if (!opts.ok) return false;
if (/no-store/i.test(opts.cacheControl || "")) return false;
if (typeof opts.bodyText === "string" && SECRET_BODY_RE.test(opts.bodyText)) {
return false;
}
return true;
}
export const ACCOUNT_CACHE_NAME_RE = /^healthlog-(?:pages|data)-/;
+6 -11
View File
@@ -109,17 +109,12 @@ export function attachRecordContextEcho(
headers.set(RECORD_SCOPE_HEADER, recordScopeHeaderValue(context.scope));
// And declare that the answer DEPENDS on those two request headers.
//
// Not a precaution against some future reverse proxy — it is load-bearing on
// a path that exists today. When a switch is driven EXTERNALLY (another tab,
// an account deletion, an operator), this browser never ran
// `clearOfflineCachesForRecordSwitch`, so the service worker's
// `healthlog-data-*` entries from the previous record are still on disk.
// `Vary` is the only thing that stops `cache.match()` handing one of them to
// the next request for the same URL under a different context — one person's
// health record served to another, out of the disk layer, with no request
// reaching the fence at all. Proved in
// `e2e/v137-record-session-fence-offline.spec.ts`, which fails the moment
// this block is removed.
// The service worker no longer stores API answers, so no disk layer of
// this app can hand one record's answer to a request made under another
// context (`e2e/v137-record-session-fence-offline.spec.ts` pins that no
// `/api/` entry reaches the disk). `Vary` keeps the same promise for any
// HTTP cache between the browser and the server, which this app does not
// control.
//
// Appended rather than assigned: a route that already varies on something
// (`Accept-Encoding`, a locale header) must keep it. Case-insensitive
+16 -15
View File
@@ -183,6 +183,11 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
{ path: "/api/auth/login", method: "POST" },
{ path: "/api/auth/passkey/login-options", method: "POST" },
{ path: "/api/auth/passkey/login-verify", method: "POST" },
// Each touches only this visitor: the session, the language cookie (the
// route leaves the shared row alone under DEMO_MODE), a metrics log line.
{ path: "/api/auth/logout", method: "POST" },
{ path: "/api/auth/me/locale", method: "PUT" },
{ path: "/api/internal/web-vitals", method: "POST" },
// Chart display-pref toggles above the charts (dashboard + insights):
{ path: "/api/dashboard/chart-overlay-prefs", method: "PUT" },
{ path: "/api/dashboard/widgets", method: "PUT" },
@@ -231,8 +236,7 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
// The per-profile routes (`/api/managed-profiles/{id}`, its guardians)
// are different paths and stay closed too.
{ path: "/api/auth/profile", method: "PUT" },
// v1.42.2 — the record search is a read that travels as a POST so the
// query stays out of URLs and access logs. It writes nothing.
// The record search: a read sent as a POST to keep the query out of logs.
{ path: "/api/search", method: "POST" },
];
@@ -473,7 +477,7 @@ export function proxy(request: NextRequest) {
);
}
// Demo mode: block all mutations except login
// Demo mode: block every mutation outside the allowlist.
if (process.env.DEMO_MODE === "true") {
const method = request.method.toUpperCase();
const isMutation =
@@ -565,20 +569,17 @@ export function proxy(request: NextRequest) {
const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic;
if (isSessionGatedPage) {
if (!readSessionCookie(request.cookies)) {
return applyBaselineSecurityHeaders(
NextResponse.redirect(new URL("/auth/login", request.url)),
);
// Back here after signing in; the sign-in page re-checks `next`.
const signIn = new URL("/auth/login", request.url);
const here = `${pathname}${request.nextUrl.search}`;
if (here !== "/") signIn.searchParams.set("next", here);
return applyBaselineSecurityHeaders(NextResponse.redirect(signIn));
}
// v1.4.22 — server-side onboarding redirect. Previously the
// `<AuthShell>` ran this in a post-hydration `useEffect`, so a user
// with `onboardingCompletedAt === null` briefly saw the dashboard
// flash before the redirect fired. The auth flows (login, passkey,
// register, /api/auth/me) keep an `hl_onboarding` cookie in sync
// with the DB state; the proxy reads it without a DB roundtrip so
// the redirect lands on the first server response. The /onboarding
// page itself is in PUBLIC_PATHS above, so this branch only runs
// for the surfaces that need to redirect away.
// The onboarding redirect, here so it lands on the first response with no
// dashboard flash. The auth flows (login, passkey, register,
// /api/auth/me) keep the `hl_onboarding` cookie in step with the DB, so no
// DB round trip; `/onboarding` itself is public above.
const onboardingPending =
request.cookies.get("hl_onboarding")?.value === "pending";
// v1.4.22 — exact match the redirect
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more