diff --git a/docs/README.md b/docs/README.md index b3e5c1186..dfe00df1a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,10 @@ notes, and machine-readable specs that ship alongside the source: managed profiles for somebody with no login and the guardians who look after them, what revocation does and does not undo, and which trust properties of the instance are unchanged by any of it. +- [`using/`](./using/) — notes on using the app that sit close to the + code. [`using/search.md`](./using/search.md) covers the command palette's + record search: opening it, what it finds, how matching works, and its + limits on very large and shared records. - [`migration/`](./migration/) — migration notes. Currently only v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and . diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2c0cf6fc7..9e3201b0e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23729,7 +23729,7 @@ paths: "422": *a4 "429": *a5 /api/search: - get: + post: tags: - Records summary: Search the record @@ -23738,30 +23738,31 @@ paths: notes on mood entries, readings and illness days. Hits are grouped by kind, the best group first, and each carries the in-app path that opens the record. Matching ignores case and accents and reads German umlauts typed as ae, oe, ue or ss. A source whose module the record has switched off is not read. The search reads each source - newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query is - never logged. Rate limited per acting account (60 a minute). A grant scoped to some sections of the record does - not open it, like the timeline." - parameters: - - in: query - name: q - schema: - type: string - minLength: 2 - maxLength: 100 - description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue - or ss. - required: true - description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue - or ss. - - in: query - name: limit - schema: - default: 5 - description: Most hits per group. - type: integer - minimum: 1 - maximum: 20 - description: Most hits per group. + newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query + travels in the JSON body, never in the URL, and is never logged. Rate limited per acting account (20 a minute), + and one search at a time: a newer search by the same account stops the one before it, which then answers 409 + `search.superseded`. A grant scoped to some sections of the record does not open it, like the timeline." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + q: + type: string + minLength: 2 + maxLength: 100 + description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue + or ss. + limit: + default: 5 + description: Most hits per group. + type: integer + minimum: 1 + maximum: 20 + required: + - q responses: "200": description: The hits, grouped by kind. diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 0aa0d0761..509ac3351 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -179,6 +179,12 @@ HealthLog publishes to that topic. **Server URL** (defaults to `https://ntfy.sh`; point it at your own server if you self-host ntfy) and **Topic**, then save. +HealthLog publishes each message as JSON to the server's root URL (the topic +travels in the body, see ntfy's +[publish as JSON](https://docs.ntfy.sh/publish/#publish-as-json)), so titles +and button labels in any language arrive as written. A reverse proxy in front +of your own ntfy has to pass `POST /` through, not only `POST /`. + If your ntfy server requires auth, an access token can be attached; the server URL is validated at save time and dialled through the egress guard (`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own @@ -474,7 +480,8 @@ once per event type, and every channel carries the same target: How each channel carries it: -- **ntfy**: the `Click` header, so tapping the notification opens the page. +- **ntfy**: the message's `click` field, so tapping the notification opens + the page. - **Telegram**: an **Open in HealthLog** button under the message, below any action buttons. Telegram only accepts `https` button links on a public host, so a plain-HTTP instance gets a text link instead, and an instance @@ -522,13 +529,31 @@ token is the only credential, so it is built to be harmless on its own: answer, so it cannot be moved to another account or another answer; - it expires six hours after the reminder was sent; - the three answers of one reminder are one use between them: the first - tap logs, every later tap on that reminder is refused; + tap logs, every later tap on that reminder is refused, and that stays + true when the logged entry is deleted and purged from the trash before + the six hours are up; - the route is rate-limited before it reads the token, refuses when the mood module has been turned off, and the token is scrubbed from logs. The buttons need `APP_URL`, because the ntfy app calls the URL from your phone. Your phone has to reach the instance at that address. +What the token cannot protect against is the topic itself. The button URLs, +token included, travel through your ntfy server inside the message, and the +message is delivered to everyone subscribed to the topic. For the six hours +a token is valid, anyone who can read the topic can log that one answer on +your behalf. On the public `ntfy.sh` the topic name is the only access +control, so: + +- use a long, random topic name and do not share it; +- better, run your own ntfy server with access control and give HealthLog + an access token that may only publish to your topic; +- to invalidate every quick-answer token at once, rotate + `API_TOKEN_HMAC_KEY`. The same key also hashes API tokens and session + cookies, so rotating it invalidates every issued API token and signs out + every browser session and the iOS app as well (see + [getting started](./getting-started.md)). + **Telegram** keeps its own keyboard on the mood reminder: the full 1 to 5 scale, a note and a "later" button. diff --git a/docs/using/search.md b/docs/using/search.md new file mode 100644 index 000000000..767d55f6f --- /dev/null +++ b/docs/using/search.md @@ -0,0 +1,55 @@ +# Searching your record + +The command palette finds pages and settings, and it searches what you have +recorded. + +## Opening it + +Press **⌘K** on a Mac or **Ctrl K** elsewhere, or tap the magnifier at the +top right, and start typing. + +## What it finds + +Below the pages and settings, a section **In your records** lists the +entries that match what you typed: + +- visits (procedures included) +- conditions +- documents, by title, file name and the text read from them +- vaccinations +- lab results +- medications +- symptoms +- workouts +- notes on mood entries, readings and illness days + +Each hit opens where the entry lives: a visit on the check-ups page, a lab +result on its analyte, a note on a mood entry or a reading on its day, a note +on an illness day in its illness. + +## How matching works + +Case and accents do not matter, so "schulter", "Schulter" and "Schülter" +find the same entries. Umlauts can be typed as `ae`, `oe` and `ue` (and `ß` +as `ss`), which helps on a keyboard without them. + +## Very large records + +The search reads each kind of entry newest first and stops after a fixed +number of entries and a short time limit, so it stays quick on a record that +holds years of data. When it stopped before reaching the oldest entries, the +list says so. A more specific search term usually finds an older entry. + +## Shared records + +When someone has shared their whole record with you, the search works inside +that record too. An account shared with you section by section cannot be +searched: the search lays every section side by side, which a partial share +does not open. + +## Privacy + +What you type is sent to your instance in the body of the request, not in the +address, so it does not end up in a reverse proxy's access log or in the +browser history. The server does not log the search text either, only its +length and how many hits it found. diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts index b6ba08930..cae66b948 100644 --- a/e2e/command-palette.spec.ts +++ b/e2e/command-palette.spec.ts @@ -31,11 +31,15 @@ test.use({ storageState: STORAGE_STATE_PATH }); const palette = (page: Page) => page.locator('[data-slot="command-palette"]'); -/** Answer `GET /api/search` with one visit hit, and count the requests. */ +/** Answer `POST /api/search` with one visit hit, and count the requests. */ async function mockRecordSearch(page: Page): Promise { const queries: string[] = []; - await page.route("**/api/search?*", async (route) => { - queries.push(new URL(route.request().url()).searchParams.get("q") ?? ""); + await page.route("**/api/search", async (route) => { + // The query rides the JSON body, never the URL. + expect(route.request().method()).toBe("POST"); + expect(route.request().url()).not.toContain("?"); + const body = route.request().postDataJSON() as { q?: string } | null; + queries.push(body?.q ?? ""); await route.fulfill({ status: 200, contentType: "application/json", diff --git a/messages/de.json b/messages/de.json index 3ddbf9f68..31294658e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6832,6 +6832,7 @@ "nocreds": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.", "token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.", + "issuer": "Die Anmeldeantwort kam nicht vom Autorisierungsserver von Oura, deshalb wurde die Verbindung zu deiner Sicherheit abgebrochen. Bitte starte die Verbindung erneut. Wenn das wieder passiert, sag deinem Administrator Bescheid.", "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." }, "integrationSetupGuide": "Einrichtungsanleitung", @@ -7239,7 +7240,7 @@ "typeCodex": "ChatGPT-Anmeldung (persönlich)", "typeLocal": "Lokales Modell (persönlich)", "typeGateway": "OpenAI-kompatibles Gateway (persönlich)", - "spendToday": "Heute ({date}): {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers.", + "spendToday": "Heute, jeweils am eigenen Kalendertag jedes Kontos: {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers (Konten mit Verbrauch: {accounts}).", "statusPaused": "Pausiert", "pausedHint": "Nach wiederholten Fehlern für {count} Nutzer pausiert bis {at}. Danach wird er automatisch wieder versucht.", "modelNotServed": "Der Endpunkt bietet das Modell {model} nicht mehr an. Wähle in den KI-Einstellungen ein Modell, das er anbietet." diff --git a/messages/en.json b/messages/en.json index 22c59c452..ba3a1b552 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6832,6 +6832,7 @@ "nocreds": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.", "token": "The token exchange with Oura failed. Please start the connection again.", "rate_limited": "Too many connection attempts. Wait a moment and try again.", + "issuer": "The sign-in answer did not come from Oura's own authorization server, so the connection was stopped for your safety. Please start the connection again. If this keeps happening, tell your administrator.", "generic": "Unknown error while connecting. Please start the connection again." }, "integrationSetupGuide": "Setup guide", @@ -7239,7 +7240,7 @@ "typeCodex": "ChatGPT sign-in (personal)", "typeLocal": "Local model (personal)", "typeGateway": "OpenAI-compatible gateway (personal)", - "spendToday": "Today ({date}): {total} tokens, {operator} of them on the operator's own key.", + "spendToday": "Today, on each account's own calendar day: {total} tokens, {operator} of them on the operator's own key (accounts with spend: {accounts}).", "statusPaused": "Paused", "pausedHint": "Paused after repeated failures for {count} users until {at}. It is tried again automatically then.", "modelNotServed": "The endpoint no longer offers the model {model}. Choose a model it lists in the AI settings." diff --git a/messages/es.json b/messages/es.json index 6bae1f78f..e58bb81a6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6832,6 +6832,7 @@ "nocreds": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.", "token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.", "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.", + "issuer": "La respuesta de inicio de sesión no procedía del servidor de autorización de Oura, así que la conexión se detuvo por tu seguridad. Vuelve a iniciar la conexión. Si sigue ocurriendo, avisa a tu administrador.", "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." }, "integrationSetupGuide": "Guía de configuración", @@ -7239,7 +7240,7 @@ "typeCodex": "Inicio de sesión de ChatGPT (personal)", "typeLocal": "Modelo local (personal)", "typeGateway": "Pasarela compatible con OpenAI (personal)", - "spendToday": "Hoy ({date}): {total} tokens, {operator} de ellos en la clave del operador.", + "spendToday": "Hoy, en el día natural de cada cuenta: {total} tokens, {operator} de ellos en la clave del operador (cuentas con consumo: {accounts}).", "statusPaused": "En pausa", "pausedHint": "En pausa tras fallos repetidos para {count} usuarios hasta {at}. Después se vuelve a intentar automáticamente.", "modelNotServed": "El endpoint ya no ofrece el modelo {model}. Elige en los ajustes de IA un modelo que ofrezca." diff --git a/messages/fr.json b/messages/fr.json index ebec3595d..fbbcd93e7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6832,6 +6832,7 @@ "nocreds": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.", "token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.", "rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.", + "issuer": "La réponse de connexion ne provenait pas du serveur d'autorisation d'Oura, la connexion a donc été interrompue par sécurité. Veuillez relancer la connexion. Si cela se reproduit, prévenez votre administrateur.", "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." }, "integrationSetupGuide": "Guide de configuration", @@ -7239,7 +7240,7 @@ "typeCodex": "Connexion ChatGPT (personnelle)", "typeLocal": "Modèle local (personnel)", "typeGateway": "Passerelle compatible OpenAI (personnelle)", - "spendToday": "Aujourd'hui ({date}) : {total} jetons, dont {operator} sur la clé de l'opérateur.", + "spendToday": "Aujourd'hui, selon le jour civil de chaque compte : {total} jetons, dont {operator} sur la clé de l'opérateur (comptes avec consommation : {accounts}).", "statusPaused": "En pause", "pausedHint": "En pause après des échecs répétés pour {count} utilisateurs jusqu’au {at}. Il sera ensuite réessayé automatiquement.", "modelNotServed": "Le point d’accès ne propose plus le modèle {model}. Choisissez dans les réglages IA un modèle qu’il propose." diff --git a/messages/it.json b/messages/it.json index 992c4b835..e33119da0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6832,6 +6832,7 @@ "nocreds": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.", "token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.", "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.", + "issuer": "La risposta di accesso non proveniva dal server di autorizzazione di Oura, quindi la connessione è stata interrotta per la tua sicurezza. Avvia di nuovo la connessione. Se succede ancora, avvisa il tuo amministratore.", "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." }, "integrationSetupGuide": "Guida alla configurazione", @@ -7239,7 +7240,7 @@ "typeCodex": "Accesso ChatGPT (personale)", "typeLocal": "Modello locale (personale)", "typeGateway": "Gateway compatibile OpenAI (personale)", - "spendToday": "Oggi ({date}): {total} token, di cui {operator} sulla chiave dell'operatore.", + "spendToday": "Oggi, secondo il giorno di calendario di ciascun account: {total} token, di cui {operator} sulla chiave dell'operatore (account con consumo: {accounts}).", "statusPaused": "In pausa", "pausedHint": "In pausa dopo errori ripetuti per {count} utenti fino al {at}. Poi viene ritentato automaticamente.", "modelNotServed": "L’endpoint non offre più il modello {model}. Scegli nelle impostazioni IA un modello che offre." diff --git a/messages/ko.json b/messages/ko.json index a813b4978..6c81ce563 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6832,6 +6832,7 @@ "nocreds": "이 서버에는 Oura가 설정돼 있지 않아요. 관리자에게 Oura OAuth 자격 증명을 추가해 달라고 요청하세요.", "token": "Oura와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", "rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.", + "issuer": "로그인 응답이 Oura의 인증 서버에서 오지 않아서 안전을 위해 연결을 중단했어요. 연결을 다시 시작해 주세요. 계속 발생하면 관리자에게 알려 주세요.", "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요." }, "integrationSetupGuide": "설정 가이드", @@ -7957,7 +7958,7 @@ "typeCodex": "ChatGPT 로그인 (개인)", "typeLocal": "로컬 모델 (개인)", "typeGateway": "OpenAI 호환 게이트웨이 (개인)", - "spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다.", + "spendToday": "오늘(각 계정의 현지 날짜 기준): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다(사용량이 있는 계정: {accounts}개).", "statusPaused": "일시 중지됨", "pausedHint": "반복된 오류로 사용자 {count}명에 대해 {at}까지 일시 중지되었습니다. 그 후 자동으로 다시 시도합니다.", "modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요." diff --git a/messages/pl.json b/messages/pl.json index 9950e6411..864d500b4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6832,6 +6832,7 @@ "nocreds": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.", "token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.", "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.", + "issuer": "Odpowiedź logowania nie pochodziła z serwera autoryzacji Oura, więc połączenie zostało przerwane dla Twojego bezpieczeństwa. Rozpocznij połączenie ponownie. Jeśli to się powtarza, powiadom administratora.", "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." }, "integrationSetupGuide": "Przewodnik konfiguracji", @@ -7239,7 +7240,7 @@ "typeCodex": "Logowanie ChatGPT (osobiste)", "typeLocal": "Model lokalny (osobisty)", "typeGateway": "Brama zgodna z OpenAI (osobista)", - "spendToday": "Dzisiaj ({date}): {total} tokenów, w tym {operator} na kluczu operatora.", + "spendToday": "Dzisiaj, według dnia kalendarzowego każdego konta: {total} tokenów, w tym {operator} na kluczu operatora (konta ze zużyciem: {accounts}).", "statusPaused": "Wstrzymany", "pausedHint": "Wstrzymany po powtarzających się błędach dla {count} użytkowników do {at}. Potem zostanie automatycznie ponowiony.", "modelNotServed": "Punkt końcowy nie oferuje już modelu {model}. Wybierz w ustawieniach AI model, który oferuje." diff --git a/prisma/migrations/0397_measurements_noted_live_idx/migration.sql b/prisma/migrations/0397_measurements_noted_live_idx/migration.sql index 20711fc33..de777e276 100644 --- a/prisma/migrations/0397_measurements_noted_live_idx/migration.sql +++ b/prisma/migrations/0397_measurements_noted_live_idx/migration.sql @@ -8,8 +8,13 @@ -- -- Additive only, no table rewrite, no data change. Non-concurrent for the -- reason migration 0243 gives: Prisma Migrate runs a migration file in a --- transaction, and the entrypoint applies migrations before the app serves --- traffic. The predicate is tiny, so the build is short. +-- transaction. The build is not short on a large instance: the predicate +-- only decides which rows land in the index, so Postgres still scans the +-- whole `measurements` table to find them, and the non-concurrent build +-- blocks writes to the table while it runs. That is acceptable because the +-- entrypoint applies migrations before the app serves traffic, so no request +-- is waiting on those writes; an upgrade on a table with many millions of +-- readings simply takes longer to come up. CREATE INDEX IF NOT EXISTS "measurements_noted_live_idx" ON "measurements" ("user_id", "measured_at" DESC, "id" DESC) WHERE "deleted_at" IS NULL diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index 272865a8e..697cba90e 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -154,6 +154,13 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { ).toBe(403); }); + it("permits the record search, a read that travels as a POST", () => { + // The query rides the body so it stays out of URLs and access logs; the + // route writes nothing, so the demo searches like any other instance. + expect(proxy(makeRequest("/api/search", "POST")).status).not.toBe(403); + expect(proxy(makeRequest("/api/search", "DELETE")).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); diff --git a/src/__tests__/read-over-post-guard.test.ts b/src/__tests__/read-over-post-guard.test.ts new file mode 100644 index 000000000..c699a9ad4 --- /dev/null +++ b/src/__tests__/read-over-post-guard.test.ts @@ -0,0 +1,34 @@ +/** + * `readOverPost` lets a delegable route answer a POST at the grant's read + * level. That widens what a read-only grant reaches, so the set of routes + * declaring it is frozen here: adding one is a reviewed diff, not an + * accident. Today it is the record search alone, whose query is health data + * that must stay out of URLs. + * + * The matcher tolerates whitespace and line breaks, and the guard asserts it + * matched something, so a rewrite that hides the declaration from the regex + * fails instead of passing on an empty set. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { globSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +const ROOT = process.cwd(); + +const FROZEN = ["src/app/api/search/route.ts"]; + +describe("readOverPost declarations", () => { + it("are exactly the frozen set", () => { + const declaring = globSync("src/**/*.ts", { cwd: ROOT }) + .filter((file) => !file.includes(`${sep}__tests__${sep}`)) + .filter((file) => file !== join("src", "lib", "api-handler.ts")) + .filter((file) => + /readOverPost\s*:\s*true/.test(readFileSync(join(ROOT, file), "utf8")), + ) + .map((file) => relative(ROOT, join(ROOT, file)).split(sep).join("/")) + .sort(); + expect(declaring.length).toBeGreaterThan(0); + expect(declaring).toEqual(FROZEN); + }); +}); diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index 6735c919b..fa9f4259e 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -21,7 +21,7 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn(async () => []), }, coachUsage: { - aggregate: vi.fn(), + findMany: vi.fn(async () => []), }, // v1.42 — the operator key's model check reads the admin settings. appSettings: { findUnique: vi.fn(async () => null) }, @@ -59,7 +59,19 @@ import { prisma } from "@/lib/db"; import { requireAdmin } from "@/lib/api-handler"; const groupBy = vi.mocked(prisma.providerHealth.groupBy); -const aggregate = vi.mocked(prisma.coachUsage.aggregate); +const usageRows = vi.mocked(prisma.coachUsage.findMany); + +/** A spend row as the reader selects it. */ +function usage( + dateKey: string, + totalTokens: number, + operatorTokens: number, + timezone: string | null = "UTC", +) { + return { dateKey, totalTokens, operatorTokens, user: { timezone } }; +} + +const utcToday = () => new Date().toISOString().slice(0, 10); function group( providerType: string, @@ -91,9 +103,7 @@ describe("GET /api/admin/provider-health", () => { vi.mocked(requireAdmin).mockResolvedValue({ user: { id: "admin1" }, } as never); - aggregate.mockResolvedValue({ - _sum: { totalTokens: 0, operatorTokens: 0 }, - } as never); + usageRows.mockResolvedValue([] as never); }); it("is admin-gated before it touches the ledger", async () => { @@ -231,31 +241,44 @@ describe("GET /api/admin/provider-health", () => { */ it("reports the day's spend split by who paid for it", async () => { groupBy.mockResolvedValue([] as never); - aggregate.mockResolvedValue({ - _sum: { totalTokens: 1_240_000, operatorTokens: 151_200 }, - } as never); + usageRows.mockResolvedValue([ + usage(utcToday(), 1_000_000, 100_000), + usage(utcToday(), 240_000, 51_200), + ] as never); const res = await GET(); const body = await res.json(); expect(body.data.spendToday).toEqual({ - dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), totalTokens: 1_240_000, operatorTokens: 151_200, + accounts: 2, + }); + }); + + it("counts each account on its own today and nothing from another day", async () => { + groupBy.mockResolvedValue([] as never); + const yesterday = new Date(Date.now() - 86_400_000) + .toISOString() + .slice(0, 10); + usageRows.mockResolvedValue([ + usage(utcToday(), 500, 100), + // The same UTC account's yesterday: already closed, not today's spend. + usage(yesterday, 9_999, 9_999), + ] as never); + + const res = await GET(); + const body = await res.json(); + expect(body.data.spendToday).toEqual({ + totalTokens: 500, + operatorTokens: 100, + accounts: 1, }); - // Today only, across every account — the operator's ceiling is per day. - expect(aggregate).toHaveBeenCalledWith( - expect.objectContaining({ - where: { dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }, - }), - ); }); it("reports a day with no spend as zero, not null", async () => { groupBy.mockResolvedValue([] as never); - aggregate.mockResolvedValue({ - _sum: { totalTokens: null, operatorTokens: null }, - } as never); + usageRows.mockResolvedValue([] as never); const res = await GET(); const body = await res.json(); @@ -281,9 +304,7 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", () vi.mocked(requireAdmin).mockResolvedValue({ user: { id: "admin" }, } as never); - aggregate.mockResolvedValue({ - _sum: { totalTokens: 0, operatorTokens: 0 }, - } as never); + usageRows.mockResolvedValue([] as never); findMany.mockResolvedValue([] as never); settings.mockResolvedValue(null as never); }); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index bfe9eeffd..8d35cada5 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -1,5 +1,5 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { buildDateKey } from "@/lib/ai/coach/budget"; +import { readInstanceSpendToday } from "@/lib/ai/coach/budget"; import { prisma } from "@/lib/db"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -73,14 +73,9 @@ interface ProviderHealthSummary { const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES]; export const GET = apiHandler(async () => { - const { user } = await requireAdmin(); + await requireAdmin(); annotate({ action: { name: "admin.provider-health.get" } }); - // The ledger buckets each account by its own local day; the operator's - // "today" is the day in the operator's own zone, which on a single-zone - // instance is everyone's. - const dateKey = await buildDateKey(user.id); - const groups = await prisma.providerHealth.groupBy({ // `lastStatus` joins the grouping key rather than an aggregate: the // MAX of two HTTP statuses is not a status, it is arithmetic on a @@ -177,17 +172,19 @@ export const GET = apiHandler(async () => { // alarming until you see that 151 200 of them were the instance's own key // and the rest ran on the users' plans. The operator ceiling is enforced // against the second figure, so both belong on screen. - const spend = await prisma.coachUsage.aggregate({ - where: { dateKey }, - _sum: { totalTokens: true, operatorTokens: true }, - }); + // + // "Today" is each account's own calendar day, the day its ceilings are + // enforced on (`readInstanceSpendToday`), not one date for the instance: + // the budget day is per account local day, so a single date key would + // count some accounts' yesterday or tomorrow and miss part of their today. + const spend = await readInstanceSpendToday(); return apiSuccess({ providers, spendToday: { - dateKey, - totalTokens: spend._sum.totalTokens ?? 0, - operatorTokens: spend._sum.operatorTokens ?? 0, + totalTokens: spend.total, + operatorTokens: spend.operator, + accounts: spend.accounts, }, }); }); diff --git a/src/app/api/notifications/action/[token]/__tests__/route.test.ts b/src/app/api/notifications/action/[token]/__tests__/route.test.ts index a77eda6cb..728fc42eb 100644 --- a/src/app/api/notifications/action/[token]/__tests__/route.test.ts +++ b/src/app/api/notifications/action/[token]/__tests__/route.test.ts @@ -10,7 +10,10 @@ import { NextRequest } from "next/server"; const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; vi.mock("@/lib/db", () => ({ - prisma: { user: { findUnique: vi.fn() } }, + prisma: { + user: { findUnique: vi.fn() }, + idempotencyKey: { create: vi.fn(), deleteMany: vi.fn() }, + }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/rate-limit", () => ({ @@ -67,6 +70,10 @@ beforeEach(() => { timezone: "Europe/Berlin", } as never); vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never); + vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({ + count: 1, + } as never); vi.mocked(logTelegramMood).mockResolvedValue({ created: true, moodEntryId: "m1", @@ -151,4 +158,42 @@ describe("POST /api/notifications/action/{token}", () => { const res = await call(token({ action: "mood-bad" })); expect(res.status).toBe(409); }); + + it("refuses a spent nonce with 409 before writing anything", async () => { + const { Prisma } = await import("@/generated/prisma/client"); + vi.mocked(prisma.idempotencyKey.create).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("unique", { + code: "P2002", + clientVersion: "test", + }), + ); + const res = await call(token()); + expect(res.status).toBe(409); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("claims the nonce until the token expires", async () => { + const now = Date.now(); + await call(token({ now })); + expect(prisma.idempotencyKey.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user-1", + key: "notification-action:nonce-1", + requestFingerprint: null, + expiresAt: new Date(now + ACTION_TOKEN_TTL_MS), + }), + }); + }); + + it("gives the claim back when the write fails for another reason", async () => { + vi.mocked(logTelegramMood).mockRejectedValue(new Error("db down")); + const res = await call(token()); + expect(res.status).toBe(500); + expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + userId: "user-1", + key: "notification-action:nonce-1", + }), + }); + }); }); diff --git a/src/app/api/notifications/action/[token]/route.ts b/src/app/api/notifications/action/[token]/route.ts index f10eb613a..cbf074576 100644 --- a/src/app/api/notifications/action/[token]/route.ts +++ b/src/app/api/notifications/action/[token]/route.ts @@ -13,10 +13,12 @@ * 2. Verify the signature (constant-time) and the expiry. * 3. Re-check what may have changed since the reminder went out: the * account still exists and its mood module is still on. - * 4. Write the mood entry under the reminder's nonce. The unique - * `(userId, source, externalId)` key makes the first answer the only + * 4. Claim the reminder's nonce (`action-nonce.ts`), then write the mood + * entry under it. The claim and the entry's unique + * `(userId, source, externalId)` key each make the first answer the only * one: a repeat tap, or a different answer on the same reminder, is - * refused with 409 and writes nothing. + * refused with 409 and writes nothing. The claim is what still holds + * after the entry was deleted and purged inside the token's lifetime. * * The token travels in the path, so it is scrubbed from every log line by * the `PATH_SECRET_PATHS` entry for this prefix, and the route sits on the @@ -31,6 +33,10 @@ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; import { logTelegramMood } from "@/lib/mood/create-from-telegram"; +import { + claimActionNonce, + releaseActionNonce, +} from "@/lib/notifications/action-nonce"; import { MOOD_QUICK_ACTIONS, quickActionExternalId, @@ -116,6 +122,23 @@ export const POST = apiHandler( }); } + const alreadyUsed = () => { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "already_used" }, + }); + return apiError("This reminder was already answered", 409, { + errorCode: "notification.action.already_used", + }); + }; + + const claimed = await claimActionNonce({ + userId: user.id, + nonce: verdict.nonce, + expiry: verdict.expiry, + }); + if (!claimed) return alreadyUsed(); + const score = MOOD_QUICK_ACTIONS[verdict.action]; let created: boolean; try { @@ -129,19 +152,15 @@ export const POST = apiHandler( created = result.created; } catch (err) { // Two taps raced past the existence check; the unique key let one in. - if (!isUniqueViolation(err)) throw err; + if (!isUniqueViolation(err)) { + // Nothing was written: give the answer back so a retry can land. + await releaseActionNonce({ userId: user.id, nonce: verdict.nonce }); + throw err; + } created = false; } - if (!created) { - annotate({ - action: { name: "notification.action.refused" }, - meta: { reason: "already_used" }, - }); - return apiError("This reminder was already answered", 409, { - errorCode: "notification.action.already_used", - }); - } + if (!created) return alreadyUsed(); annotate({ action: { name: "notification.action.mood_logged" }, diff --git a/src/app/api/search/__tests__/route.test.ts b/src/app/api/search/__tests__/route.test.ts index 82e3ae25f..479e3598f 100644 --- a/src/app/api/search/__tests__/route.test.ts +++ b/src/app/api/search/__tests__/route.test.ts @@ -1,6 +1,6 @@ /** - * `GET /api/search` — admission, validation, the rate limit, and what the - * route hands the search and the log. + * `POST /api/search` — admission, validation, the rate limit, one search in + * flight per actor, and what the route hands the search and the log. * * The search itself is `searchRecords`, covered by its own unit tests and by * `tests/integration/record-search.test.ts` against Postgres; here it is a @@ -49,12 +49,27 @@ vi.mock("@/lib/search/search-records", () => ({ searchRecords: mocks.searchRecords, })); -import { GET } from "../route"; +import { POST } from "../route"; +import { searchesInFlight } from "@/lib/search/in-flight"; -const handler = GET as unknown as (req: NextRequest) => Promise; +const handler = POST as unknown as (req: NextRequest) => Promise; -function request(query: string): NextRequest { - return new NextRequest(`http://localhost/api/search?${query}`); +/** + * A search request. Written as the query string the route used to take, so + * every case reads as before; the route receives it as a JSON body. + */ +function request(query: string, init: { signal?: AbortSignal } = {}) { + const params = new URLSearchParams(query); + const body: Record = {}; + for (const [key, value] of params) { + body[key] = key === "limit" && /^\d+$/.test(value) ? Number(value) : value; + } + return new NextRequest("http://localhost/api/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: init.signal, + }); } const OWNER = { id: "owner-1", locale: "de" }; @@ -93,10 +108,13 @@ beforeEach(() => { }); }); -describe("GET /api/search", () => { +describe("POST /api/search", () => { it("admits the whole record only, at read", async () => { await handler(request("q=schulter")); - expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "record"); + // A POST that only reads, declared as such. + expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "record", { + readOverPost: true, + }); }); it("propagates a refused admission without searching", async () => { @@ -137,6 +155,7 @@ describe("GET /api/search", () => { ["a zero limit", "q=knie&limit=0"], ["a limit above the cap", "q=knie&limit=21"], ["a non-numeric limit", "q=knie&limit=lots"], + ["a fractional limit", "q=knie&limit=2.5"], ])("refuses %s with 422 and never searches", async (_label, query) => { const res = await handler(request(query)); expect(res.status).toBe(422); @@ -173,4 +192,108 @@ describe("GET /api/search", () => { delegated: true, }); }); + + it("keeps the query out of the URL", async () => { + const req = request("q=Psychotherapie"); + expect(req.url).not.toMatch(/psychotherapie/i); + const res = await handler(req); + expect(res.status).toBe(200); + expect(mocks.searchRecords.mock.calls[0][0].query).toBe("Psychotherapie"); + }); + + it("refuses a body that is not JSON", async () => { + const res = await handler( + new NextRequest("http://localhost/api/search", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: "q=schulter", + }), + ); + expect(res.status).toBe(415); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("allows 20 searches a minute per actor", async () => { + await handler(request("q=schulter")); + expect(mocks.checkRateLimit).toHaveBeenCalledWith( + "search:actor-1", + 20, + 60_000, + ); + }); + + it("hands the search an abort signal that follows the client", async () => { + const client = new AbortController(); + let seen: AbortSignal | undefined; + mocks.searchRecords.mockImplementationOnce(async (args) => { + seen = args.signal; + client.abort(); + return { + groups: [], + truncated: true, + stats: { sources: 1, rows: 0, candidates: 0, elapsedMs: 1 }, + }; + }); + await handler(request("q=schulter", { signal: client.signal })); + expect(seen?.aborted).toBe(true); + }); + + it("stops an actor's earlier search when a newer one starts", async () => { + let release: () => void = () => {}; + let firstSignal: AbortSignal | undefined; + mocks.searchRecords.mockImplementationOnce( + (args) => + new Promise((resolve) => { + firstSignal = args.signal; + release = () => + resolve({ + groups: [], + truncated: true, + stats: { sources: 1, rows: 0, candidates: 0, elapsedMs: 1 }, + }); + }), + ); + const first = handler(request("q=schul")); + await vi.waitFor(() => expect(firstSignal).toBeDefined()); + expect(firstSignal?.aborted).toBe(false); + + const second = await handler(request("q=schulter")); + expect(second.status).toBe(200); + expect(firstSignal?.aborted).toBe(true); + + release(); + const superseded = await first; + expect(superseded.status).toBe(409); + expect((await superseded.json()).meta?.errorCode).toBe("search.superseded"); + expect(searchesInFlight()).toBe(0); + }); + + it("does not stop another actor's search", async () => { + let firstSignal: AbortSignal | undefined; + let release: () => void = () => {}; + mocks.searchRecords.mockImplementationOnce( + (args) => + new Promise((resolve) => { + firstSignal = args.signal; + release = () => + resolve({ + groups: [], + truncated: false, + stats: { sources: 1, rows: 0, candidates: 0, elapsedMs: 1 }, + }); + }), + ); + const first = handler(request("q=schul")); + await vi.waitFor(() => expect(firstSignal).toBeDefined()); + + mocks.requireRecordAuth.mockResolvedValueOnce({ + user: { id: "owner-2", locale: "de" }, + actor: { id: "actor-2", locale: "de" }, + grantId: null, + }); + await handler(request("q=knie")); + expect(firstSignal?.aborted).toBe(false); + release(); + expect((await first).status).toBe(200); + }); }); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 855c9007c..8493f39a7 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,5 +1,6 @@ /** - * `GET /api/search?q=&limit=` — one search over the record (Discussion #1117): + * `POST /api/search` with `{ q, limit }` — one search over the record + * (Discussion #1117): * visits and procedures, conditions, documents (title and indexed text), * vaccinations, lab results, medications, symptoms, workouts and the notes on * mood entries, readings and illness days. Each hit carries the in-app path @@ -12,36 +13,54 @@ * switched-off module is not read at all. * * The query is health data ("Schulter", a drug, a diagnosis). It never - * reaches a log: the annotation carries its length and the counts only, and a - * refused query is answered without echoing it. + * reaches a log: it travels in the JSON body rather than the URL, so neither + * this app's request log nor a reverse proxy's access log records it; the + * annotation carries its length and the counts only, and a refused query is + * answered without echoing it. * - * Rate limited per actor: the palette asks once per pause in typing, and a - * manager searching someone's record spends their own allowance. + * Bounded per actor: 20 searches a minute (the palette asks once per pause in + * typing, and a manager searching someone's record spends their own + * allowance), and one at a time. A newer search by the same actor aborts the + * one before it, and a client that goes away aborts its own, so an abandoned + * search stops reading at its next budget check instead of running out its + * time (`in-flight.ts`). */ import { NextRequest } from "next/server"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; import { resolveModuleMap } from "@/lib/modules/gate"; import { checkRateLimit } from "@/lib/rate-limit"; +import { beginSearch, SUPERSEDED } from "@/lib/search/in-flight"; import { searchRecords } from "@/lib/search/search-records"; import { searchQuerySchema } from "@/lib/search/wire-schemas"; import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import { resolveUserTimezone } from "@/lib/tz/resolver"; -/** Searches per actor per minute: a few per typed word, with room to spare. */ -const SEARCH_RATE_LIMIT = 60; +/** Searches per actor per minute: one per pause in typing, with room to spare. */ +const SEARCH_RATE_LIMIT = 20; const SEARCH_RATE_WINDOW_MS = 60_000; +/** The body is a query of at most 100 characters and a number. */ +const SEARCH_BODY_MAX_BYTES = 2048; function displayLocale(value: string | null | undefined): Locale { return locales.includes(value as Locale) ? (value as Locale) : defaultLocale; } -export const GET = apiHandler(async (request: NextRequest) => { - const { user, actor, grantId } = await requireRecordAuth("read", "record"); +export const POST = apiHandler(async (request: NextRequest) => { + // A POST that only reads: the query rides the body to stay out of URLs, and + // a read grant on the whole record is what it needs, as it was over GET. + const { user, actor, grantId } = await requireRecordAuth("read", "record", { + readOverPost: true, + }); const rl = await checkRateLimit( `search:${actor.id}`, @@ -52,10 +71,14 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiError("Too many searches. Please retry shortly.", 429); } - const params = new URL(request.url).searchParams; + const { data: body, error: jsonError } = await safeJson<{ + q?: unknown; + limit?: unknown; + }>(request, { maxBytes: SEARCH_BODY_MAX_BYTES }); + if (jsonError) return jsonError; const parsed = searchQuerySchema.safeParse({ - q: params.get("q") ?? undefined, - limit: params.get("limit") ?? undefined, + q: body?.q ?? undefined, + limit: body?.limit ?? undefined, }); if (!parsed.success) { annotate({ @@ -67,35 +90,54 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } - const [modules, domainVisible, timezone] = await Promise.all([ - resolveModuleMap(user.id), - actingDomainVisibility(prisma, grantId), - resolveUserTimezone(user.id), - ]); - const result = await searchRecords({ - recordId: user.id, - query: parsed.data.q, - limit: parsed.data.limit, - modules, - domainVisible, - displayLocale: displayLocale(actor.locale), - timezone, - }); - - annotate({ - action: { name: "search.query.run" }, - meta: { - query_length: parsed.data.q.length, + const slot = beginSearch(actor.id); + try { + const [modules, domainVisible, timezone] = await Promise.all([ + resolveModuleMap(user.id), + actingDomainVisibility(prisma, grantId), + resolveUserTimezone(user.id), + ]); + const result = await searchRecords({ + recordId: user.id, + query: parsed.data.q, limit: parsed.data.limit, - sources: result.stats.sources, - rows_scanned: result.stats.rows, - matches: result.stats.candidates, - groups: result.groups.length, - hits: result.groups.reduce((n, g) => n + g.hits.length, 0), - truncated: result.truncated, - elapsed_ms: result.stats.elapsedMs, - delegated: grantId !== null, - }, - }); - return apiSuccess({ groups: result.groups, truncated: result.truncated }); + modules, + domainVisible, + displayLocale: displayLocale(actor.locale), + timezone, + signal: AbortSignal.any([request.signal, slot.signal]), + }); + + if (slot.signal.aborted) { + // A newer search by the same actor took over; this answer is partial + // and nobody is waiting for it. + annotate({ + action: { name: "search.query.superseded" }, + meta: { query_length: parsed.data.q.length }, + }); + return apiError("A newer search replaced this one", 409, { + errorCode: SUPERSEDED, + }); + } + + annotate({ + action: { name: "search.query.run" }, + meta: { + query_length: parsed.data.q.length, + limit: parsed.data.limit, + sources: result.stats.sources, + rows_scanned: result.stats.rows, + matches: result.stats.candidates, + groups: result.groups.length, + hits: result.groups.reduce((n, g) => n + g.hits.length, 0), + truncated: result.truncated, + elapsed_ms: result.stats.elapsedMs, + delegated: grantId !== null, + aborted: request.signal.aborted, + }, + }); + return apiSuccess({ groups: result.groups, truncated: result.truncated }); + } finally { + slot.end(); + } }); diff --git a/src/app/mood/__tests__/add-param-record-scope.test.tsx b/src/app/mood/__tests__/add-param-record-scope.test.tsx new file mode 100644 index 000000000..684dc743c --- /dev/null +++ b/src/app/mood/__tests__/add-param-record-scope.test.tsx @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; + +/** + * `/mood?add=1` is the mood reminder's tap target. It opens the entry sheet + * only in the caller's own record: a session that is inside a managed + * profile or a shared record opens the page without the sheet, so a reminder + * meant for the recipient never becomes a write form on somebody else. + */ + +const caps = { + current: { + inSharedRecord: false, + recordSessionPending: undefined as boolean | undefined, + accessRefused: undefined as boolean | undefined, + canManageDomain: (_d: string) => true, + }, +}; +const params = { current: new URLSearchParams("add=1") }; + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => caps.current, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { modules: {} }, + isAuthenticated: true, + isLoading: false, + }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-pull-to-refresh", () => ({ + usePullToRefresh: () => ({}), +})); +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: () => {} }), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: () => {}, push: () => {} }), + useSearchParams: () => params.current, +})); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ open }: { open: boolean; children?: ReactNode }) => + open ?
: null, +})); +vi.mock("@/components/mood/mood-form", () => ({ MoodForm: () => null })); +vi.mock("@/components/mood/mood-list", () => ({ MoodList: () => null })); +vi.mock("@/components/ui/pull-to-refresh-indicator", () => ({ + PullToRefreshIndicator: () => null, +})); + +import MoodPageClient from "../page-client"; + +function sheetOpen(): boolean { + return renderToStaticMarkup().includes( + 'data-slot="mood-sheet-open"', + ); +} + +beforeEach(() => { + params.current = new URLSearchParams("add=1"); + caps.current = { + inSharedRecord: false, + recordSessionPending: undefined, + accessRefused: undefined, + canManageDomain: () => true, + }; +}); + +describe("/mood ?add=1 and the record on screen", () => { + it("opens the entry sheet in the caller's own record", () => { + expect(sheetOpen()).toBe(true); + }); + + it("keeps the sheet closed without the param", () => { + params.current = new URLSearchParams(); + expect(sheetOpen()).toBe(false); + }); + + it("does not open the sheet inside a managed profile the caller manages", () => { + // A guardian switched into a managed profile holds MANAGE on mind, so + // the sheet would otherwise open and save onto that profile. + caps.current = { ...caps.current, inSharedRecord: true }; + expect(sheetOpen()).toBe(false); + }); + + it("does not open the sheet while the record context is pending or refused", () => { + caps.current = { + ...caps.current, + inSharedRecord: true, + recordSessionPending: true, + canManageDomain: () => false, + }; + expect(sheetOpen()).toBe(false); + caps.current = { + ...caps.current, + recordSessionPending: undefined, + accessRefused: true, + }; + expect(sheetOpen()).toBe(false); + }); +}); diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx index 25464b0b7..5891e6a15 100644 --- a/src/app/mood/page-client.tsx +++ b/src/app/mood/page-client.tsx @@ -21,7 +21,12 @@ import { useTranslations } from "@/lib/i18n/context"; export default function MoodPageClient() { const { user, isAuthenticated, isLoading } = useAuth(); - const { canManageDomain, inSharedRecord } = useRecordCapabilities(); + const { + canManageDomain, + inSharedRecord, + recordSessionPending, + accessRefused, + } = useRecordCapabilities(); // A mood entry is created at MANAGE under the mind section. const canAddMood = canManageDomain("mind"); const mounted = useMounted(); @@ -33,15 +38,35 @@ export default function MoodPageClient() { // hydration render agree; the effect below strips the one-shot param so the // back button does not reopen the sheet. A later client-side navigation // back onto `?add=1` is handled render-side, the measurements-page pattern. + // + // The request is held apart from the sheet's own open state because it is + // honoured only in the caller's own record. A reminder is about the person + // who receives it; a session that is currently inside a managed profile or + // a shared record would otherwise open a write form there, and an entry + // saved from it lands on the wrong person. In that case the page opens + // without the sheet and the shell's shared-record banner offers the way + // back. The answer is read once the account query settled (`isLoading` + // gates the content render), so the request is dropped, not deferred: a + // later switch back to the own record must not pop a stale sheet. const addParam = searchParams.get("add") === "1"; - const [dialogOpen, setDialogOpen] = useState(() => addParam); + const [dialogOpen, setDialogOpen] = useState(false); + const [addRequested, setAddRequested] = useState(() => addParam); const [consumedAdd, setConsumedAdd] = useState(() => addParam); if (!addParam && consumedAdd) { setConsumedAdd(false); } else if (addParam && !consumedAdd) { setConsumedAdd(true); - setDialogOpen(true); + setAddRequested(true); } + const ownRecord = !inSharedRecord && !recordSessionPending && !accessRefused; + if (addRequested && mounted && !isLoading && !ownRecord) { + setAddRequested(false); + } + const sheetOpen = (dialogOpen || (addRequested && ownRecord)) && canAddMood; + const setSheetOpen = useCallback((open: boolean) => { + setDialogOpen(open); + if (!open) setAddRequested(false); + }, []); // The list's empty state carries the add action while there is nothing // to list, so the header drops its own then. const [listEmpty, setListEmpty] = useState(false); @@ -68,7 +93,7 @@ export default function MoodPageClient() { ); const pull = usePullToRefresh({ onRefresh: refreshVisible, - disabled: dialogOpen, + disabled: sheetOpen, }); useEffect(() => { @@ -149,14 +174,14 @@ export default function MoodPageClient() { /> } > setDialogOpen(false)} - onCancel={() => setDialogOpen(false)} + onSuccess={() => setSheetOpen(false)} + onCancel={() => setSheetOpen(false)} footerSlot={footerEl} /> diff --git a/src/components/admin/__tests__/provider-health-spend-today.test.tsx b/src/components/admin/__tests__/provider-health-spend-today.test.tsx index bfe056560..5b334023d 100644 --- a/src/components/admin/__tests__/provider-health-spend-today.test.tsx +++ b/src/components/admin/__tests__/provider-health-spend-today.test.tsx @@ -42,24 +42,26 @@ function render(data: object | null) { } describe(" — today's spend by cost owner", () => { - it("shows both figures and the day they belong to", () => { + it("shows both figures, the account count, and which day they count", () => { const html = render({ providers: [], spendToday: { - dateKey: "2026-09-11", totalTokens: 1_240_000, operatorTokens: 151_200, + accounts: 3, }, }); - expect(html).toContain("2026-09-11"); expect(html).toContain("1,240,000"); expect(html).toContain("151,200"); + expect(html).toContain("accounts with spend: 3"); + // Each account's own day, not one date for the whole instance. + expect(html).toContain("each account's own calendar day"); }); it("renders a zero day rather than hiding the readout", () => { const html = render({ providers: [], - spendToday: { dateKey: "2026-09-11", totalTokens: 0, operatorTokens: 0 }, + spendToday: { totalTokens: 0, operatorTokens: 0, accounts: 0 }, }); expect(html).toContain('data-slot="ai-spend-today"'); }); diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index c0e0c066e..c54a88b65 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -34,10 +34,11 @@ import { formatDateTime } from "@/lib/format"; * the users' own plans. The operator ceiling is enforced against the second * figure, so both belong on screen. */ +/** Summed over every account's own current day, the day its ceilings use. */ interface SpendToday { - dateKey: string; totalTokens: number; operatorTokens: number; + accounts: number; } interface ProviderHealthRow { @@ -107,7 +108,7 @@ export function ProviderHealthSection() { {spendToday ? (

{t("admin.providerHealth.spendToday", { - date: spendToday.dateKey, + accounts: groupDigits(spendToday.accounts), total: groupDigits(spendToday.totalTokens), operator: groupDigits(spendToday.operatorTokens), })} diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx index 04564b64b..14b19727f 100644 --- a/src/components/command-palette/command-palette.tsx +++ b/src/components/command-palette/command-palette.tsx @@ -51,7 +51,7 @@ import { useRecordSearch } from "./use-record-search"; * stays open. * * After the local results comes "In your records": the server's search over - * the record (`useRecordSearch`, `GET /api/search`), once the typing pauses. + * the record (`useRecordSearch`, `POST /api/search`), once the typing pauses. * Its hits are options like any other, after the local ones, so the arrows * and Enter work across both; each opens the page that shows the record. * diff --git a/src/components/command-palette/use-record-search.ts b/src/components/command-palette/use-record-search.ts index 80e436730..9c2b56ccf 100644 --- a/src/components/command-palette/use-record-search.ts +++ b/src/components/command-palette/use-record-search.ts @@ -4,7 +4,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { apiGet } from "@/lib/api/api-fetch"; +import { apiPost } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { SEARCH_QUERY_MAX, @@ -37,7 +37,8 @@ export interface RecordSearchState { * record shared whole. A grant over some sections never opens the search, so * it is not asked. The request carries the query's abort signal: when the * next keystroke changes the key, the previous query loses its observer and - * its request is cancelled rather than left to land late. + * its request is cancelled rather than left to land late, and the server + * stops reading for it as well. */ export function useRecordSearch(rawQuery: string): RecordSearchState { const { inSharedRecord, sections } = useRecordCapabilities(); @@ -49,12 +50,12 @@ export function useRecordSearch(rawQuery: string): RecordSearchState { const result = useQuery({ queryKey: queryKeys.recordSearch(query, PALETTE_RECORD_LIMIT), + // A POST with the query in the body, so it never stands in a URL that a + // proxy's access log or the browser history could keep. queryFn: ({ signal }) => - apiGet( - `/api/search?${new URLSearchParams({ - q: query, - limit: String(PALETTE_RECORD_LIMIT), - })}`, + apiPost( + "/api/search", + { q: query, limit: PALETTE_RECORD_LIMIT }, { signal }, ), enabled, diff --git a/src/components/settings/__tests__/oauth-return-outcome.test.ts b/src/components/settings/__tests__/oauth-return-outcome.test.ts index 5a607a881..e768a03dd 100644 --- a/src/components/settings/__tests__/oauth-return-outcome.test.ts +++ b/src/components/settings/__tests__/oauth-return-outcome.test.ts @@ -61,9 +61,42 @@ describe("oauthReasonKey", () => { ); }); + it("names Oura's issuer refusal with its own copy, and only for Oura", () => { + expect(oauthReasonKey("oura", "issuer")).toBe( + "settings.ouraOauthError.issuer", + ); + // No other callback emits it, and no other subtree carries the copy. + expect(oauthReasonKey("polar", "issuer")).toBe( + "settings.polarOauthError.generic", + ); + }); + it("falls back to generic for an unknown tag", () => { expect(oauthReasonKey("whoop", "totally_unknown")).toBe( "settings.whoopOauthError.generic", ); }); }); + +describe("the Oura callback's reasons", () => { + it("each has its own copy, none falls back to generic", async () => { + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const route = readFileSync( + join(process.cwd(), "src/app/api/oura/callback/route.ts"), + "utf8", + ); + const reasons = [...route.matchAll(/ERR\("([a-z_0-9]+)"\)/g)].map( + (m) => m[1], + ); + expect(reasons).toContain("issuer"); + const en = JSON.parse( + readFileSync(join(process.cwd(), "messages/en.json"), "utf8"), + ) as { settings: { ouraOauthError: Record } }; + for (const reason of reasons) { + const key = oauthReasonKey("oura", reason); + expect(key, reason).toBe(`settings.ouraOauthError.${reason}`); + expect(en.settings.ouraOauthError[reason], reason).toBeTruthy(); + } + }); +}); diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index 89dbfeb89..52877436f 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -127,6 +127,17 @@ const OAUTH_OUTCOME_REASONS = new Set([ "expired", ]); +/** + * Reason tags only one provider's callback emits, with copy under that + * provider's subtree alone. Oura's `issuer`: the authorization response named + * an issuer that is not Oura's (RFC 9207), so the callback refused the code. + */ +const PROVIDER_ONLY_REASONS: Partial< + Record> +> = { + oura: new Set(["issuer"]), +}; + type OAuthOutcome = | { provider: OAuthOutcomeProvider; kind: "connected" } | { provider: OAuthOutcomeProvider; kind: "error"; reason: string }; @@ -162,7 +173,8 @@ export function oauthReasonKey( reason: string, ): string { const base = OAUTH_ERROR_KEY_BASE[provider]; - return OAUTH_OUTCOME_REASONS.has(reason) + return OAUTH_OUTCOME_REASONS.has(reason) || + PROVIDER_ONLY_REASONS[provider]?.has(reason) ? `${base}.${reason}` : `${base}.generic`; } diff --git a/src/hooks/__tests__/deep-linked-record.test.ts b/src/hooks/__tests__/deep-linked-record.test.ts new file mode 100644 index 000000000..a8b0aa48c --- /dev/null +++ b/src/hooks/__tests__/deep-linked-record.test.ts @@ -0,0 +1,96 @@ +/** + * `/checkups?visit=` from a search hit. The visits list reads the newest + * hundred of each arm, and a search reaches back further than that, so a hit + * can name a visit the list does not hold. That visit is read on its own and + * opened like a listed one. + */ +import { describe, expect, it, vi } from "vitest"; + +import { openDeepLinked } from "../use-deep-linked-record"; + +type Visit = { id: string; occurredAt: string }; + +/** The newest hundred visits, the way the list holds them. */ +const newestHundred: Visit[] = Array.from({ length: 100 }, (_, i) => ({ + id: `v${i}`, + occurredAt: `2026-01-01T00:00:00Z`, +})); +const older: Visit = { id: "v-older", occurredAt: "2014-05-06T09:00:00Z" }; + +describe("a link to a visit older than the newest hundred", () => { + it("reads it on its own and opens it", async () => { + const resolve = vi.fn(async (id: string) => + id === older.id ? older : null, + ); + const open = vi.fn(); + const scrollTo = vi.fn(); + + const outcome = await openDeepLinked({ + wanted: older.id, + records: newestHundred, + open, + resolve, + stillWanted: () => true, + scrollTo, + }); + + expect(outcome).toBe("resolved"); + expect(resolve).toHaveBeenCalledWith("v-older"); + expect(open).toHaveBeenCalledWith(older); + // Not on the page, so there is nothing to scroll to. + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("opens a listed visit without a second read", async () => { + const resolve = vi.fn(); + const open = vi.fn(); + const scrollTo = vi.fn(); + const outcome = await openDeepLinked({ + wanted: "v42", + records: newestHundred, + open, + resolve, + stillWanted: () => true, + scrollTo, + }); + expect(outcome).toBe("listed"); + expect(resolve).not.toHaveBeenCalled(); + expect(scrollTo).toHaveBeenCalledWith("v42"); + expect(open).toHaveBeenCalledWith(newestHundred[42]); + }); + + it("leaves the page alone when the visit is gone or refused", async () => { + const open = vi.fn(); + for (const resolve of [ + async () => null, + async () => { + throw new Error("403"); + }, + ]) { + const outcome = await openDeepLinked({ + wanted: "v-gone", + records: newestHundred, + open, + resolve, + stillWanted: () => true, + scrollTo: () => {}, + }); + expect(outcome).toBe("none"); + } + expect(open).not.toHaveBeenCalled(); + }); + + it("does not open a late answer for a link that is no longer wanted", async () => { + const open = vi.fn(); + const outcome = await openDeepLinked({ + wanted: older.id, + records: newestHundred, + open, + resolve: async () => older, + stillWanted: () => false, + scrollTo: () => {}, + }); + expect(outcome).toBe("none"); + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-deep-linked-record.ts b/src/hooks/use-deep-linked-record.ts index 56582669d..5cfe09d3b 100644 --- a/src/hooks/use-deep-linked-record.ts +++ b/src/hooks/use-deep-linked-record.ts @@ -56,24 +56,52 @@ export function useDeepLinkedRecord({ useEffect(() => { if (!wanted || !records || handled.current === wanted) return; handled.current = wanted; - const record = records.find((entry) => entry.id === wanted); - if (!record) { - if (!resolve || !open) return; - void resolve(wanted) - .then((found) => { - // Still on the page, and still the link it was asked for. - if (found && mounted.current && handled.current === wanted) { - open(found); - } - }) - .catch(() => { - // Gone or refused: the page stays as it would without the link. - }); - return; - } - document - .querySelector(`[${anchorAttribute}="${CSS.escape(wanted)}"]`) - ?.scrollIntoView({ block: "center" }); - open?.(record); + void openDeepLinked({ + wanted, + records, + open, + resolve, + // Still on the page, and still the link it was asked for. + stillWanted: () => mounted.current && handled.current === wanted, + scrollTo: (id) => + document + .querySelector(`[${anchorAttribute}="${CSS.escape(id)}"]`) + ?.scrollIntoView({ block: "center" }), + }); }, [wanted, records, anchorAttribute, open, resolve]); } + +/** + * What a link does once the list has loaded, apart from React: a record the + * list holds is scrolled to and opened; one it does not hold (older than the + * bounded read, say the newest hundred visits) is read on its own through + * `resolve` and opened when it comes back and the link is still wanted. + * Resolves to which of the three happened. + */ +export async function openDeepLinked(args: { + wanted: string; + records: readonly T[]; + open?: (record: T) => void; + resolve?: (id: string) => Promise; + stillWanted: () => boolean; + scrollTo: (id: string) => void; +}): Promise<"listed" | "resolved" | "none"> { + const { wanted, records, open, resolve } = args; + const record = records.find((entry) => entry.id === wanted); + if (record) { + args.scrollTo(wanted); + open?.(record); + return "listed"; + } + if (!resolve || !open) return "none"; + let found: T | null; + try { + found = await resolve(wanted); + } catch { + // Gone or refused: the page stays as it would without the link. + return "none"; + } + if (!found || !args.stillWanted()) return "none"; + open(found); + return "resolved"; +} diff --git a/src/lib/__tests__/acting-account-resolver.test.ts b/src/lib/__tests__/acting-account-resolver.test.ts index 46cb1cf0c..6ed62b275 100644 --- a/src/lib/__tests__/acting-account-resolver.test.ts +++ b/src/lib/__tests__/acting-account-resolver.test.ts @@ -505,6 +505,29 @@ describe("requireRecordAuth — every way a grant can fail", () => { ); }); + it("reads a POST at read level only where the route declares readOverPost", async () => { + signedInCookie(OWNER); + liveGrant(); + const declared = route(() => + requireRecordAuth("read", "record", { readOverPost: true }), + ); + expect((await declared("POST")).status).toBe(200); + // The declaration covers POST alone: every other write method still + // needs a write grant. + for (const method of ["PUT", "PATCH", "DELETE"]) { + expect((await declared(method)).status, method).toBe(403); + } + }); + + it("does not lower a declared write for a readOverPost route", async () => { + signedInCookie(OWNER); + liveGrant(); + const response = await route(() => + requireRecordAuth("write", "record", { readOverPost: true }), + )("POST"); + expect(response.status).toBe(403); + }); + it("refuses a declared write under a read grant", async () => { signedInCookie(OWNER); liveGrant(); diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index aea8edaa7..c4511b2c8 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -5,6 +5,7 @@ import { OPERATOR_COST_CAP, USER_PLAN_CAP, INTERACTIVE_RESERVE_SHARE, + readInstanceSpendToday, resolveCostOwner, resolveDailyCap, resolveDailyCapFor, @@ -15,6 +16,7 @@ vi.mock("@/lib/db", () => ({ prisma: { coachUsage: { findUnique: vi.fn(), + findMany: vi.fn(), upsert: vi.fn(), }, $executeRaw: vi.fn(), @@ -31,7 +33,10 @@ const { zoneOf } = vi.hoisted(() => ({ : "UTC", ), })); -vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: zoneOf })); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: zoneOf, + resolveServerDefaultTimezone: async () => "Europe/Berlin", +})); /** * The budget day turns at the account's own midnight, the boundary the daily @@ -616,3 +621,51 @@ describe("OPERATOR_COST_CAP (F2 — reasoning-aware operator cap)", () => { expect(USER_PLAN_CAP).toBeGreaterThan(OPERATOR_COST_CAP); }); }); + +/** + * The operator's "today" is every account's own today. One date key for the + * whole instance counted some accounts' yesterday or tomorrow once the budget + * day turned local. + */ +describe("readInstanceSpendToday", () => { + it("sums each account's row for its own current day and nothing else", async () => { + const { prisma } = await import("@/lib/db"); + // 22:30 UTC on May 10: Berlin is on May 11, New York and UTC on May 10. + const at = new Date("2026-05-10T22:30:00.000Z"); + const row = ( + dateKey: string, + total: number, + operator: number, + timezone: string | null, + ) => ({ + dateKey, + totalTokens: total, + operatorTokens: operator, + user: { timezone }, + }); + vi.mocked(prisma.coachUsage.findMany).mockResolvedValue([ + row("2026-05-11", 100, 10, "Europe/Berlin"), // Berlin today + row("2026-05-10", 1_000, 1_000, "Europe/Berlin"), // Berlin yesterday + row("2026-05-10", 200, 20, "America/New_York"), // New York today + row("2026-05-11", 7_000, 7_000, "America/New_York"), // not yet begun + row("2026-05-10", 300, 30, "UTC"), // UTC today + // No usable zone: the server default (Berlin here), so May 11. + row("2026-05-11", 40, 4, null), + row("2026-05-11", 50, 5, "+05:30"), + ] as never); + + await expect(readInstanceSpendToday(at)).resolves.toEqual({ + total: 100 + 200 + 300 + 40 + 50, + operator: 10 + 20 + 30 + 4 + 5, + accounts: 5, + }); + // Only the three keys any zone can be on right now are read. + expect(prisma.coachUsage.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + dateKey: { in: ["2026-05-09", "2026-05-10", "2026-05-11"] }, + }, + }), + ); + }); +}); diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 2849190b2..25dba016a 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -14,8 +14,11 @@ * every other per-day surface. */ import { prisma } from "@/lib/db"; -import { userDayKey } from "@/lib/tz/format"; -import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { isValidTimezone, shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { + resolveServerDefaultTimezone, + resolveUserTimezone, +} from "@/lib/tz/resolver"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; /** @@ -229,6 +232,65 @@ export async function readDailySpend( }; } +/** The day's spend across every account, for the operator's readout. */ +export interface InstanceDailySpend extends DailySpend { + /** Accounts with a spend row on their own current day. */ + accounts: number; +} + +/** + * Every account's spend on that account's own current day, summed. + * + * Since the budget day turned local, one date key no longer names "today" + * for everybody: at 23:30 in Berlin a row keyed with the Berlin date belongs + * to a Berlin account's today, while an account in New York is still on the + * day before. Each ceiling is enforced on the account's own day, so the + * operator's figure is the sum of exactly those rows, one per account: the + * spend that counts against the ceilings right now. + * + * Every zone's today lies within one day of the UTC date, so the read + * fetches those three keys only and keeps the rows whose key is their + * account's current day, resolved the way `buildDateKey` resolves it (the + * account's zone when valid, otherwise the server default). + */ +export async function readInstanceSpendToday( + at: Date = new Date(), +): Promise { + const utcKey = userDayKey(at, "UTC"); + const rows = await prisma.coachUsage.findMany({ + where: { + dateKey: { + in: [shiftDateKey(utcKey, -1), utcKey, shiftDateKey(utcKey, 1)], + }, + }, + select: { + dateKey: true, + totalTokens: true, + operatorTokens: true, + user: { select: { timezone: true } }, + }, + }); + const serverTz = await resolveServerDefaultTimezone(); + const todayByZone = new Map(); + const spend: InstanceDailySpend = { total: 0, operator: 0, accounts: 0 }; + for (const row of rows) { + const zone = + row.user.timezone && isValidTimezone(row.user.timezone) + ? row.user.timezone + : serverTz; + let today = todayByZone.get(zone); + if (today === undefined) { + today = userDayKey(at, zone); + todayByZone.set(zone, today); + } + if (row.dateKey !== today) continue; + spend.total += row.totalTokens; + spend.operator += row.operatorTokens; + spend.accounts += 1; + } + return spend; +} + /** * The budget day of one account at `at` (default now): the calendar day in * the account's own time zone. diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 4f00f4b9e..7c6a1603b 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -889,6 +889,19 @@ export interface RecordAuthOptions { * else. */ readonly scope?: string; + /** + * v1.42.2 — this route answers a POST without changing anything: the body + * carries a query that must not stand in a URL (the record search, whose + * text is health data a proxy's access log would otherwise keep). Under a + * route declaring `"read"`, a POST is then read at the grant's read level + * instead of being escalated to a write. + * + * Like `scope`, naming it is a WIDENING and is frozen by + * `read-over-post-guard.test.ts`, so it lands in a reviewed diff. It does + * nothing for any other method, and nothing for a route declaring a need + * above `"read"`. + */ + readonly readOverPost?: true; } /** @@ -980,8 +993,11 @@ export async function requireRecordAuth( } const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase(); + const readsOnly = + READ_HTTP_METHODS.has(method) || + (options?.readOverPost === true && method === "POST"); const effectiveNeed: GrantNeed = - need !== "read" || !READ_HTTP_METHODS.has(method) ? escalate(need) : "read"; + need !== "read" || !readsOnly ? escalate(need) : "read"; return resolveSwitchedRecord(auth, carrier, (grant) => { if (!grantAllows(grant, effectiveNeed)) return "insufficient_access"; diff --git a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts new file mode 100644 index 000000000..aa80d605d --- /dev/null +++ b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts @@ -0,0 +1,65 @@ +/** + * A collection counts toward a backfill's progress only once its resume note + * is written. The job reads the size of `done` as progress and queues another + * slice on it, so a key added before a write that failed would let a chain of + * slices "progress" over the same collection without end. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock, warnings } = vi.hoisted(() => ({ + prismaMock: { + googleHealthConnection: { update: vi.fn() }, + }, + warnings: [] as string[], +})); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/logging/context", () => ({ + annotate: () => {}, + getEvent: () => ({ addWarning: (w: string) => warnings.push(w) }), +})); + +import { + runGoogleHealthCollection, + runWithGoogleHealthBackfillResume, +} from "../sync-core"; + +beforeEach(() => { + vi.clearAllMocks(); + warnings.length = 0; +}); + +describe("runGoogleHealthCollection under a backfill", () => { + it("adds a clean collection to done once its note is written", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const done = new Set(["fetchSteps"]); + + await runWithGoogleHealthBackfillResume("u1", done, () => + runGoogleHealthCollection("sleep", async () => 3), + ); + + expect([...done]).toEqual(["fetchSteps", "sleep"]); + expect(prismaMock.googleHealthConnection.update).toHaveBeenCalledWith({ + where: { userId: "u1" }, + data: { backfillProgress: { done: ["fetchSteps", "sleep"] } }, + }); + }); + + it("counts no progress when the note cannot be written", async () => { + prismaMock.googleHealthConnection.update.mockRejectedValue( + new Error("connection reset"), + ); + const done = new Set(["fetchSteps"]); + + const { result } = await runWithGoogleHealthBackfillResume("u1", done, () => + runGoogleHealthCollection("sleep", async () => 3), + ); + + // The rows are in, the collection did not fail, but it is not finished. + expect(result).toBe(3); + expect([...done]).toEqual(["fetchSteps"]); + expect(warnings.some((w) => w.includes("progress write failed"))).toBe( + true, + ); + }); +}); diff --git a/src/lib/google-health/collection-keys.ts b/src/lib/google-health/collection-keys.ts new file mode 100644 index 000000000..f6304891c --- /dev/null +++ b/src/lib/google-health/collection-keys.ts @@ -0,0 +1,29 @@ +/** + * Every collection a Google Health backfill walks, by the key its resume note + * stores (`runGoogleHealthCollection`). The key type is what that function + * accepts, so a collection added without an entry here does not compile, and + * the backfill's slice ceiling (`GOOGLE_HEALTH_BACKFILL_MAX_SLICE`) is derived + * from this list rather than counted by hand. + */ +export const GOOGLE_HEALTH_COLLECTION_KEYS = [ + "fetchSteps", + "fetchDistance", + "fetchActiveEnergy", + "fetchFloors", + "fetchVo2Max", + "fetchWeight", + "fetchBodyFat", + "fetchSpo2", + "fetchHrv", + "fetchRhr", + "fetchRespiratoryRate", + "fetchHeartRate", + "fetchBloodGlucose", + "fetchCoreBodyTemperature", + "fetchSleepTemperature", + "fetchExercise", + "sleep", +] as const; + +export type GoogleHealthCollectionKey = + (typeof GOOGLE_HEALTH_COLLECTION_KEYS)[number]; diff --git a/src/lib/google-health/sync-activity.ts b/src/lib/google-health/sync-activity.ts index 32344e993..0c17e2209 100644 --- a/src/lib/google-health/sync-activity.ts +++ b/src/lib/google-health/sync-activity.ts @@ -46,6 +46,7 @@ import { type GoogleHealthResourceSyncOptions, } from "./sync-core"; import { annotate } from "@/lib/logging/context"; +import type { GoogleHealthCollectionKey } from "./collection-keys"; import { resolveUserTimezone } from "@/lib/tz/resolver"; /** One mappable activity metric: its data-type encoding + the mapper + a verb. */ @@ -55,7 +56,7 @@ interface ActivityResource { point: Record, tz: string, ) => GoogleHealthMappedMeasurement[]; - verb: string; + verb: GoogleHealthCollectionKey; } /** The four cumulative daily totals — read via `POST :dailyRollUp`. */ diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index de64e573d..dc215266d 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -20,6 +20,7 @@ import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; import { encrypt, decrypt } from "@/lib/crypto"; import { annotate, getEvent } from "@/lib/logging/context"; +import type { GoogleHealthCollectionKey } from "./collection-keys"; import { userDayKey } from "@/lib/tz/format"; import { isP2002 } from "@/lib/prisma-errors"; import { @@ -464,7 +465,7 @@ export async function runWithGoogleHealthBackfillResume( * budget asking it to stop). Anything less is walked again next attempt. */ export async function runGoogleHealthCollection( - key: string, + key: GoogleHealthCollectionKey, fn: () => Promise, ): Promise { const resume = backfillResumeStorage.getStore(); @@ -483,19 +484,27 @@ export async function runGoogleHealthCollection( !truncatedBefore && !googleHealthOutcomeTruncated(); if (clean) { - resume.done.add(key); - await prisma.googleHealthConnection - .update({ + // The collection counts as finished only once the note says so. The job + // reads the size of `done` as this attempt's progress and sends another + // slice on it; a key added before a write that then failed would be + // progress nobody can see next attempt, and a chain of slices could keep + // "progressing" over the same collection forever. + try { + await prisma.googleHealthConnection.update({ where: { userId: resume.userId }, - data: { backfillProgress: { done: [...resume.done] } }, - }) - .catch((err) => - // Losing the note costs a re-walk of this collection next attempt, - // nothing more; it must not fail a collection that did complete. - getEvent()?.addWarning( - `google-health: backfill progress write failed: ${err}`, - ), + data: { backfillProgress: { done: [...resume.done, key] } }, + }); + resume.done.add(key); + } catch (err) { + // Not finished, then: the collection is walked again next attempt. It + // must not fail a collection whose rows are written. + getEvent()?.addWarning( + `google-health: backfill progress write failed for ${key}: ${err}`, ); + annotate({ + meta: { "googleHealth.backfill.progressWriteFailed": key }, + }); + } } return imported; } diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index 59deed1d7..a86dfb59e 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -55,6 +55,7 @@ import { type GoogleHealthResourceSyncOptions, } from "./sync-core"; import { getEvent } from "@/lib/logging/context"; +import type { GoogleHealthCollectionKey } from "./collection-keys"; import { prisma } from "@/lib/db"; import { resolveUserTimezone } from "@/lib/tz/resolver"; @@ -65,7 +66,7 @@ interface MetricResource { point: Record, tz: string, ) => GoogleHealthMappedMeasurement[]; - verb: string; + verb: GoogleHealthCollectionKey; /** Page ceiling for the walk; the client default when omitted. */ maxPages?: number; /** diff --git a/src/lib/jobs/__tests__/google-health-backfill.test.ts b/src/lib/jobs/__tests__/google-health-backfill.test.ts index 8afde8dd0..4e5e5a269 100644 --- a/src/lib/jobs/__tests__/google-health-backfill.test.ts +++ b/src/lib/jobs/__tests__/google-health-backfill.test.ts @@ -69,7 +69,10 @@ vi.mock("@/lib/google-health/sync", () => ({ syncUserGoogleHealth: (...a: unknown[]) => syncUserGoogleHealthMock(...a), })); -import { runGoogleHealthBackfillForUser } from "../google-health-backfill"; +import { + GOOGLE_HEALTH_BACKFILL_MAX_SLICE, + runGoogleHealthBackfillForUser, +} from "../google-health-backfill"; import { GOOGLE_HEALTH_DATA_TYPES, forEachDataPointPage, @@ -229,6 +232,39 @@ describe("runGoogleHealthBackfillForUser — slices", () => { ); }); + it("fails a slice past the ceiling without walking or queueing", async () => { + syncUserGoogleHealthMock.mockResolvedValue({ imported: 1, failed: true }); + resumeState.finishes = ["sleep"]; + + await expect( + runGoogleHealthBackfillForUser( + "u1", + undefined, + GOOGLE_HEALTH_BACKFILL_MAX_SLICE + 1, + ), + ).rejects.toThrow(/past the ceiling/); + expect(syncUserGoogleHealthMock).not.toHaveBeenCalled(); + expect(bossSend).not.toHaveBeenCalled(); + }); + + it("does not queue a slice past the ceiling even when this one progressed", async () => { + resumeState.finishes = ["sleep"]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 1, failed: true }); + + await expect( + runGoogleHealthBackfillForUser( + "u1", + undefined, + GOOGLE_HEALTH_BACKFILL_MAX_SLICE, + ), + ).rejects.toThrow(/incomplete/); + expect(bossSend).not.toHaveBeenCalled(); + }); + + it("caps the chain at the number of collections plus two", () => { + expect(GOOGLE_HEALTH_BACKFILL_MAX_SLICE).toBe(17 + 2); + }); + it("stamps the marker on the slice that finishes the history", async () => { resumeState.doneBefore = ["steps", "sleep"]; resumeState.finishes = ["weight"]; diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index bb3e66b42..4f3a00294 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -30,6 +30,7 @@ import { } from "@/lib/google-health/sync"; import { GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS } from "@/lib/google-health/sync-lock"; import { runWithGoogleHealthStop } from "@/lib/google-health/client"; +import { GOOGLE_HEALTH_COLLECTION_KEYS } from "@/lib/google-health/collection-keys"; export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill"; @@ -40,6 +41,17 @@ export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill"; */ export const GOOGLE_HEALTH_BACKFILL_CONCURRENCY = 1; +/** + * The highest slice a backfill chain may reach. Every continued slice has + * finished at least one more collection than the one before it, so a healthy + * chain is at most one slice per collection long; the two extra slices are + * slack for the first one and the one that stamps the marker. A slice past + * this is a chain that counts progress it does not make, and it fails rather + * than queueing another. + */ +export const GOOGLE_HEALTH_BACKFILL_MAX_SLICE = + GOOGLE_HEALTH_COLLECTION_KEYS.length + 2; + export interface GoogleHealthBackfillPayload { userId: string; enqueuedAt: string; @@ -78,7 +90,10 @@ export interface GoogleHealthBackfillPayload { * `continued`, without the error line or the queue's failure count. Only an * attempt that finished nothing new throws, because that one is stuck: the * pg-boss retry and, after it, the next boot take it from there. Progress is - * bounded by the number of collections, so the chain of slices is too. + * counted only once it is persisted (`runGoogleHealthCollection` adds a + * collection to `done` after its note is written), and the chain is capped at + * `GOOGLE_HEALTH_BACKFILL_MAX_SLICE`: a slice past it throws instead of + * running or sending another. * * Never beside another run of the same account: the sync takes the account's * lock, waiting a bounded time for an hourly or manual run to finish. If the @@ -90,6 +105,17 @@ export async function runGoogleHealthBackfillForUser( shouldStop: () => boolean = () => false, slice: number = 0, ): Promise<{ imported: number; continued?: boolean }> { + if (slice > GOOGLE_HEALTH_BACKFILL_MAX_SLICE) { + annotate({ + action: { + name: "google_health.backfill.slice_ceiling", + details: { slice, maxSlice: GOOGLE_HEALTH_BACKFILL_MAX_SLICE }, + }, + }); + throw new Error( + `google-health backfill for user ${userId} reached slice ${slice}, past the ceiling of ${GOOGLE_HEALTH_BACKFILL_MAX_SLICE}`, + ); + } if (await isGoogleHealthParked(userId)) { annotate({ action: { @@ -143,7 +169,7 @@ export async function runGoogleHealthBackfillForUser( // `done` is the resume set this attempt added to as it finished // collections; a larger set than it started with is progress. const finished = done.size - doneBefore; - if (finished > 0) { + if (finished > 0 && slice + 1 <= GOOGLE_HEALTH_BACKFILL_MAX_SLICE) { const boss = getGlobalBoss(); const sent = boss ? await enqueueIntegrationBackfillAdmission( diff --git a/src/lib/mcp/__tests__/record-candidates-budget.test.ts b/src/lib/mcp/__tests__/record-candidates-budget.test.ts new file mode 100644 index 000000000..26e74fbdf --- /dev/null +++ b/src/lib/mcp/__tests__/record-candidates-budget.test.ts @@ -0,0 +1,111 @@ +/** + * `loadRecordCandidates` under the in-app search's budget: a kind stops + * building candidates once `shouldStop` says so, keeps its newest rows, and + * says it did not see everything; a kind not started when the budget is + * spent is not read at all. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock } = vi.hoisted(() => ({ + prismaMock: { + user: { findUnique: vi.fn(async () => null) }, + encounter: { findMany: vi.fn() }, + illnessEpisode: { findMany: vi.fn(async () => []) }, + }, +})); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); + +import { loadRecordCandidates } from "../record-search"; + +function encounters(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: `e${i}`, + userId: "u1", + occurredAt: new Date(Date.UTC(2026, 0, 30 - i)), + kind: "CHECKUP", + status: "COMPLETED", + reasonEncrypted: null, + outcomeEncrypted: null, + bodySiteEncrypted: null, + laterality: null, + practitioner: null, + })); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("loadRecordCandidates under a budget", () => { + it("stops a kind part-way, newest first, and reports it", async () => { + prismaMock.encounter.findMany.mockResolvedValue(encounters(10)); + let checks = 0; + const truncated: string[] = []; + + const out = await loadRecordCandidates("u1", "visit", "", { + kinds: new Set(["visit"]), + rowsPerKind: 100, + // One check before the kind starts, then one per row: the fifth + // check, before the fourth row, says stop. + shouldStop: () => ++checks > 4, + onTruncated: (kind) => truncated.push(kind), + }); + + expect(out.map((c) => c.id)).toEqual(["visit:e0", "visit:e1", "visit:e2"]); + expect(truncated).toContain("visit"); + }); + + it("does not read a kind once the budget is already spent", async () => { + const truncated: string[] = []; + const out = await loadRecordCandidates("u1", "visit", "", { + kinds: new Set(["visit", "condition"]), + shouldStop: () => true, + onTruncated: (kind) => truncated.push(kind), + }); + expect(out).toEqual([]); + expect(prismaMock.encounter.findMany).not.toHaveBeenCalled(); + expect(prismaMock.illnessEpisode.findMany).not.toHaveBeenCalled(); + expect(truncated.sort()).toEqual(["condition", "visit"]); + }); + + it("reads everything and reports nothing without a budget", async () => { + prismaMock.encounter.findMany.mockResolvedValue(encounters(10)); + const truncated: string[] = []; + const out = await loadRecordCandidates("u1", "visit", "", { + kinds: new Set(["visit"]), + rowsPerKind: 100, + onTruncated: (kind) => truncated.push(kind), + }); + expect(out).toHaveLength(10); + expect(truncated).toEqual([]); + }); + + it("does not call a kind holding exactly its cap truncated", async () => { + prismaMock.encounter.findMany.mockResolvedValue(encounters(5)); + const truncated: string[] = []; + const out = await loadRecordCandidates("u1", "visit", "", { + kinds: new Set(["visit"]), + rowsPerKind: 5, + onTruncated: (kind) => truncated.push(kind), + }); + expect(out).toHaveLength(5); + expect(truncated).toEqual([]); + // One row past the cap is asked for, as the probe. + expect(prismaMock.encounter.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 6 }), + ); + }); + + it("calls a kind with a row past its cap truncated, and keeps the cap", async () => { + prismaMock.encounter.findMany.mockResolvedValue(encounters(6)); + const truncated: string[] = []; + const out = await loadRecordCandidates("u1", "visit", "", { + kinds: new Set(["visit"]), + rowsPerKind: 5, + onTruncated: (kind) => truncated.push(kind), + }); + expect(out).toHaveLength(5); + expect(truncated).toEqual(["visit"]); + }); +}); diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts index 3cbd2d8f7..79cca5ebd 100644 --- a/src/lib/mcp/record-search.ts +++ b/src/lib/mcp/record-search.ts @@ -20,7 +20,7 @@ * uses. The document body itself is never read for a search. * * Ranking and folding live in `@/lib/search/match`, shared with the in-app - * search (`GET /api/search`), which reads its record kinds through + * search (`POST /api/search`), which reads its record kinds through * `loadRecordCandidates` here as well: one matcher, one set of record reads. * * Module switches. A record kind whose module the account has off (or the @@ -349,7 +349,7 @@ async function visitCandidates( userId: string, owner: Owner, origin: string, - take: number, + read: KindRead, empty: boolean, ): Promise { const rows = await prisma.encounter.findMany({ @@ -361,66 +361,70 @@ async function visitCandidates( ...(empty ? { occurredAt: { lte: new Date() } } : {}), }, orderBy: [{ occurredAt: "desc" }, { id: "asc" }], - take, + take: probeTake(read), include: { practitioner: PRACTITIONER_SELECT }, }); - return rows.map((row) => { - const practitioner = livePractitioner(row.practitioner); - const reason = decryptText(row.reasonEncrypted); - const outcome = decryptText(row.outcomeEncrypted); - const bodySite = decryptText(row.bodySiteEncrypted); - const day = userDayKey(row.occurredAt, owner.timezone); - const kindLabel = encounterKindLabel(row.kind, owner.display); - const title = reason?.replace(/\s+/g, " ").trim() || null; - return { - id: `visit:${row.id}`, - title: visitTitle({ - kind: row.kind, - day, - bodySite: bodySite?.replace(/\s+/g, " ").trim() ?? null, - laterality: row.laterality, - practitioner: practitioner?.name ?? null, - status: row.status, - }), - url: recordUrl(origin, "visit", row.id), - kindOrder: KIND_ORDER.visit, - date: row.occurredAt.getTime(), - record: true, - hit: { - kind: "visit", - recordId: row.id, - title: title ?? kindLabel, - context: compact([ - title ? kindLabel : null, - practitioner?.name, - sitePhrase(bodySite, row.laterality, owner.display), - ]), - day, - path: recordPath("visit", row.id), - }, - fields: [ - { text: bodySite ?? "", weight: WEIGHT.primary }, - { text: reason ?? "", weight: WEIGHT.primary, body: true }, - { text: practitioner?.name ?? "", weight: WEIGHT.primary }, - { text: practitioner?.specialty ?? "", weight: WEIGHT.primary }, - { - text: [ - "visit", - enumWords(row.kind), - encounterKindLabel(row.kind, "en"), - encounterKindLabel(row.kind, owner.locale), - ENCOUNTER_KIND_ALIASES[row.kind] ?? "", - ].join(" "), - weight: WEIGHT.secondary, + return mapUntilStopped( + withinCap(rows, read), + read, + (row): SearchCandidate => { + const practitioner = livePractitioner(row.practitioner); + const reason = decryptText(row.reasonEncrypted); + const outcome = decryptText(row.outcomeEncrypted); + const bodySite = decryptText(row.bodySiteEncrypted); + const day = userDayKey(row.occurredAt, owner.timezone); + const kindLabel = encounterKindLabel(row.kind, owner.display); + const title = reason?.replace(/\s+/g, " ").trim() || null; + return { + id: `visit:${row.id}`, + title: visitTitle({ + kind: row.kind, + day, + bodySite: bodySite?.replace(/\s+/g, " ").trim() ?? null, + laterality: row.laterality, + practitioner: practitioner?.name ?? null, + status: row.status, + }), + url: recordUrl(origin, "visit", row.id), + kindOrder: KIND_ORDER.visit, + date: row.occurredAt.getTime(), + record: true, + hit: { + kind: "visit", + recordId: row.id, + title: title ?? kindLabel, + context: compact([ + title ? kindLabel : null, + practitioner?.name, + sitePhrase(bodySite, row.laterality, owner.display), + ]), + day, + path: recordPath("visit", row.id), }, - { - text: sideTerms(row.laterality, owner.locale) ?? "", - weight: WEIGHT.secondary, - }, - { text: outcome ?? "", weight: WEIGHT.secondary, body: true }, - ], - }; - }); + fields: [ + { text: bodySite ?? "", weight: WEIGHT.primary }, + { text: reason ?? "", weight: WEIGHT.primary, body: true }, + { text: practitioner?.name ?? "", weight: WEIGHT.primary }, + { text: practitioner?.specialty ?? "", weight: WEIGHT.primary }, + { + text: [ + "visit", + enumWords(row.kind), + encounterKindLabel(row.kind, "en"), + encounterKindLabel(row.kind, owner.locale), + ENCOUNTER_KIND_ALIASES[row.kind] ?? "", + ].join(" "), + weight: WEIGHT.secondary, + }, + { + text: sideTerms(row.laterality, owner.locale) ?? "", + weight: WEIGHT.secondary, + }, + { text: outcome ?? "", weight: WEIGHT.secondary, body: true }, + ], + }; + }, + ); } /** @@ -476,58 +480,62 @@ async function conditionCandidates( userId: string, owner: Owner, origin: string, - take: number, + read: KindRead, ): Promise { const rows = await prisma.illnessEpisode.findMany({ where: { userId, deletedAt: null }, orderBy: [{ onsetAt: "desc" }, { id: "asc" }], - take, + take: probeTake(read), select: CONDITION_SELECT, }); - return rows.map((row) => { - const bodySite = decryptText(row.bodySiteEncrypted); - const day = userDayKey(row.onsetAt, owner.timezone); - const site = bodySite?.replace(/\s+/g, " ").trim(); - return { - id: `condition:${row.id}`, - title: scrubFenceMarkers( - `${row.label}${site ? ` · ${site}` : ""} (${illnessTypeLabel(row.type, "en")}, since ${day})`, - ), - url: recordUrl(origin, "condition", row.id), - kindOrder: KIND_ORDER.condition, - date: row.onsetAt.getTime(), - record: true, - hit: { - kind: "condition", - recordId: row.id, - title: row.label, - context: compact([ - illnessTypeLabel(row.type, owner.display), - sitePhrase(bodySite, row.laterality, owner.display), - ]), - day, - path: recordPath("condition", row.id), - }, - fields: [ - { text: row.label, weight: WEIGHT.primary }, - { text: bodySite ?? "", weight: WEIGHT.primary }, - { - text: [ - "condition illness", - enumWords(row.type), - illnessTypeLabel(row.type, "en"), - illnessTypeLabel(row.type, owner.locale), - enumWords(row.lifecycle), - ].join(" "), - weight: WEIGHT.secondary, + return mapUntilStopped( + withinCap(rows, read), + read, + (row): SearchCandidate => { + const bodySite = decryptText(row.bodySiteEncrypted); + const day = userDayKey(row.onsetAt, owner.timezone); + const site = bodySite?.replace(/\s+/g, " ").trim(); + return { + id: `condition:${row.id}`, + title: scrubFenceMarkers( + `${row.label}${site ? ` · ${site}` : ""} (${illnessTypeLabel(row.type, "en")}, since ${day})`, + ), + url: recordUrl(origin, "condition", row.id), + kindOrder: KIND_ORDER.condition, + date: row.onsetAt.getTime(), + record: true, + hit: { + kind: "condition", + recordId: row.id, + title: row.label, + context: compact([ + illnessTypeLabel(row.type, owner.display), + sitePhrase(bodySite, row.laterality, owner.display), + ]), + day, + path: recordPath("condition", row.id), }, - { - text: sideTerms(row.laterality, owner.locale) ?? "", - weight: WEIGHT.secondary, - }, - ], - }; - }); + fields: [ + { text: row.label, weight: WEIGHT.primary }, + { text: bodySite ?? "", weight: WEIGHT.primary }, + { + text: [ + "condition illness", + enumWords(row.type), + illnessTypeLabel(row.type, "en"), + illnessTypeLabel(row.type, owner.locale), + enumWords(row.lifecycle), + ].join(" "), + weight: WEIGHT.secondary, + }, + { + text: sideTerms(row.laterality, owner.locale) ?? "", + weight: WEIGHT.secondary, + }, + ], + }; + }, + ); } const DOCUMENT_SELECT = { @@ -604,18 +612,18 @@ async function documentCandidates( userId: string, owner: Owner, origin: string, - take: number, + read: KindRead, query: string, words: readonly string[], includeAiDeferred: boolean, ): Promise { const nameTerms = documentNameTerms(query, words); - const [rows, content, named] = await Promise.all([ + const [probed, content, named] = await Promise.all([ prisma.inboundDocument.findMany({ where: { userId, deletedAt: null }, select: DOCUMENT_SELECT, orderBy: [{ createdAt: "desc" }, { id: "asc" }], - take, + take: probeTake(read), }), words.length > 0 ? contentMatches(userId, words, includeAiDeferred) @@ -638,6 +646,9 @@ async function documentCandidates( }) : Promise.resolve([] as Array<{ id: string }>), ]); + // The cap applies to the newest read; the extra rows below are content and + // name hits beyond it, already bounded by their own read. + const rows = withinCap(probed, read); // A content or name hit on a document older than the newest `take` still // counts; the extra read is bounded like the others. const seen = new Set(rows.map((r) => r.id)); @@ -654,7 +665,7 @@ async function documentCandidates( }) : []; - return [...rows, ...extra].map((row) => { + return mapUntilStopped([...rows, ...extra], read, (row): SearchCandidate => { const dated = row.documentDate ?? row.reportDate; const kindLabel = documentKindLabel(row.kind, "en"); const name = row.title ?? row.filename ?? kindLabel; @@ -705,73 +716,77 @@ async function vaccinationCandidates( userId: string, owner: Owner, origin: string, - take: number, + read: KindRead, ): Promise { const rows = await prisma.vaccinationRecord.findMany({ where: { userId, deletedAt: null }, orderBy: [{ occurredAt: "desc" }, { id: "asc" }], - take, + take: probeTake(read), select: VACCINATION_SELECT, }); - return rows.map((row) => { - const practitioner = livePractitioner(row.practitioner); - const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug }); - const own = ownVaccine(row); - const catalogEn = vaccineCatalogName(row.antigenSlug, "en"); - const name = - row.vaccineName ?? - catalogEn ?? - own?.name ?? - row.antigenSlug ?? - "Vaccination"; - const dose = row.doseNumber ? `dose ${row.doseNumber}, ` : ""; - const day = dateOnly(row.occurredAt); - return { - id: `vaccination:${row.id}`, - title: scrubFenceMarkers(`${name} (${dose}${day})`), - url: recordUrl(origin, "vaccination", row.id), - kindOrder: KIND_ORDER.vaccination, - date: row.occurredAt.getTime(), - record: true, - hit: { - kind: "vaccination", - recordId: row.id, - title: - row.vaccineName ?? - vaccineCatalogName(row.antigenSlug, owner.display) ?? - own?.name ?? - row.antigenSlug ?? - null, - context: compact([practitioner?.name]), - day, - path: recordPath("vaccination", row.id), - }, - fields: [ - { text: row.vaccineName ?? "", weight: WEIGHT.primary }, - { - text: [ - catalogEn ?? "", - vaccineCatalogName(row.antigenSlug, owner.locale) ?? "", - row.antigenSlug - ? enumWords(row.antigenSlug.replace(/-/g, "_")) - : "", - ...(entry?.synonyms ?? []), - own?.name ?? "", - ...(own?.components ?? []).map((antigen) => - enumWords(antigen.replace(/-/g, "_")), - ), - ].join(" "), - weight: WEIGHT.primary, + return mapUntilStopped( + withinCap(rows, read), + read, + (row): SearchCandidate => { + const practitioner = livePractitioner(row.practitioner); + const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug }); + const own = ownVaccine(row); + const catalogEn = vaccineCatalogName(row.antigenSlug, "en"); + const name = + row.vaccineName ?? + catalogEn ?? + own?.name ?? + row.antigenSlug ?? + "Vaccination"; + const dose = row.doseNumber ? `dose ${row.doseNumber}, ` : ""; + const day = dateOnly(row.occurredAt); + return { + id: `vaccination:${row.id}`, + title: scrubFenceMarkers(`${name} (${dose}${day})`), + url: recordUrl(origin, "vaccination", row.id), + kindOrder: KIND_ORDER.vaccination, + date: row.occurredAt.getTime(), + record: true, + hit: { + kind: "vaccination", + recordId: row.id, + title: + row.vaccineName ?? + vaccineCatalogName(row.antigenSlug, owner.display) ?? + own?.name ?? + row.antigenSlug ?? + null, + context: compact([practitioner?.name]), + day, + path: recordPath("vaccination", row.id), }, - { - text: "vaccination vaccine immunization impfung", - weight: WEIGHT.secondary, - }, - { text: practitioner?.name ?? "", weight: WEIGHT.secondary }, - { text: practitioner?.specialty ?? "", weight: WEIGHT.secondary }, - ], - }; - }); + fields: [ + { text: row.vaccineName ?? "", weight: WEIGHT.primary }, + { + text: [ + catalogEn ?? "", + vaccineCatalogName(row.antigenSlug, owner.locale) ?? "", + row.antigenSlug + ? enumWords(row.antigenSlug.replace(/-/g, "_")) + : "", + ...(entry?.synonyms ?? []), + own?.name ?? "", + ...(own?.components ?? []).map((antigen) => + enumWords(antigen.replace(/-/g, "_")), + ), + ].join(" "), + weight: WEIGHT.primary, + }, + { + text: "vaccination vaccine immunization impfung", + weight: WEIGHT.secondary, + }, + { text: practitioner?.name ?? "", weight: WEIGHT.secondary }, + { text: practitioner?.specialty ?? "", weight: WEIGHT.secondary }, + ], + }; + }, + ); } export interface RecordSearchOptions { @@ -790,6 +805,62 @@ export interface RecordSearchOptions { includeAiDeferredText?: boolean; /** Newest rows read per kind; defaults to `MAX_ROWS_PER_KIND`. */ rowsPerKind?: number; + /** + * Asked before each kind starts and before every row a kind decrypts. + * Once it says stop, the kind keeps what it has (its newest rows) and + * `onTruncated` is told. The in-app search hands its shared time budget + * and the request's abort signal in here. + */ + shouldStop?: () => boolean; + /** + * Told when a kind did not see all of its rows: it filled its newest + * `rowsPerKind` read, or `shouldStop` ended it part-way. + */ + onTruncated?: (kind: RecordKind) => void; +} + +/** How one kind reads: its row cap, and the stop and truncation hooks. */ +interface KindRead { + take: number; + shouldStop: () => boolean; + onTruncated: () => void; +} + +/** + * A kind's newest read asks for one row past its cap. The extra row only + * answers whether anything older is left: a kind holding exactly `take` rows + * was read whole and is not reported cut short. + */ +function probeTake(read: KindRead): number { + return read.take + 1; +} + +/** The rows within the cap, telling `onTruncated` when the probe row came. */ +function withinCap(rows: Row[], read: KindRead): Row[] { + if (rows.length <= read.take) return rows; + read.onTruncated(); + return rows.slice(0, read.take); +} + +/** + * Map a kind's rows newest first until `shouldStop` says stop. The rows are + * read already; what this bounds is the decrypting and the building, which is + * where a large record spends its time. + */ +function mapUntilStopped( + rows: readonly Row[], + read: KindRead, + build: (row: Row) => Out, +): Out[] { + const out: Out[] = []; + for (const row of rows) { + if (read.shouldStop()) { + read.onTruncated(); + break; + } + out.push(build(row)); + } + return out; } /** @@ -811,26 +882,40 @@ export async function loadRecordCandidates( options.kinds ? Promise.resolve(options.kinds) : readableKinds(userId), loadOwner(userId, options.displayLocale), ]); + const shouldStop = options.shouldStop ?? (() => false); + const reading = (kind: RecordKind): KindRead | null => { + if (!kinds.has(kind)) return null; + const onTruncated = () => options.onTruncated?.(kind); + if (shouldStop()) { + onTruncated(); + return null; + } + return { take, shouldStop, onTruncated }; + }; + const visit = reading("visit"); + const condition = reading("condition"); + const document = reading("document"); + const vaccination = reading("vaccination"); const groups = await Promise.all([ - kinds.has("visit") - ? visitCandidates(userId, owner, origin, take, empty) + visit + ? visitCandidates(userId, owner, origin, visit, empty) : Promise.resolve([]), - kinds.has("condition") - ? conditionCandidates(userId, owner, origin, take) + condition + ? conditionCandidates(userId, owner, origin, condition) : Promise.resolve([]), - kinds.has("document") + document ? documentCandidates( userId, owner, origin, - take, + document, query, words, options.includeAiDeferredText === true, ) : Promise.resolve([]), - kinds.has("vaccination") - ? vaccinationCandidates(userId, owner, origin, take) + vaccination + ? vaccinationCandidates(userId, owner, origin, vaccination) : Promise.resolve([]), ]); return groups.flat(); diff --git a/src/lib/notifications/action-nonce.ts b/src/lib/notifications/action-nonce.ts new file mode 100644 index 000000000..df0e49a8b --- /dev/null +++ b/src/lib/notifications/action-nonce.ts @@ -0,0 +1,71 @@ +/** + * The record that a quick-action nonce was spent. + * + * A reminder's answers are single-use through the mood entry they write: its + * unique `(userId, source, externalId)` key refuses a second entry under the + * same nonce. That holds only while the entry exists. A user who answers, + * then deletes the entry and purges it from the trash inside the token's + * six hours, frees the key, and the same notification button would write a + * second entry. So the nonce is claimed on its own as well, in the + * idempotency table, which already holds one-shot claims under a unique key + * and is swept by expiry (`idempotency-cleanup.ts`). The claim expires with + * the token, so the table holds it exactly as long as it matters. + * + * The row can never be read back as a cached response: its method is not an + * HTTP verb, its path names no route, and its fingerprint is null, which + * `withIdempotency` never replays. + */ +import { prisma } from "@/lib/db"; +import { isP2002 } from "@/lib/prisma-errors"; +import { quickActionExternalId } from "@/lib/notifications/action-token"; + +const CLAIM_METHOD = "NOTIFICATION_ACTION"; +const CLAIM_PATH = "/api/notifications/action"; +const CLAIM_STATUS = 0; + +/** + * Claim a reminder's nonce. `true` when this call won it, `false` when the + * nonce was already spent (by an earlier answer or a concurrent one). + */ +export async function claimActionNonce(input: { + userId: string; + nonce: string; + expiry: number; +}): Promise { + try { + await prisma.idempotencyKey.create({ + data: { + userId: input.userId, + key: quickActionExternalId(input.nonce), + method: CLAIM_METHOD, + path: CLAIM_PATH, + requestFingerprint: null, + responseStatus: CLAIM_STATUS, + responseBody: "", + expiresAt: new Date(input.expiry), + }, + }); + return true; + } catch (err) { + if (isP2002(err)) return false; + throw err; + } +} + +/** + * Give a claim back when the write it guarded failed for a reason other than + * the nonce being spent, so the person can tap the answer again. + */ +export async function releaseActionNonce(input: { + userId: string; + nonce: string; +}): Promise { + await prisma.idempotencyKey.deleteMany({ + where: { + userId: input.userId, + key: quickActionExternalId(input.nonce), + method: CLAIM_METHOD, + path: CLAIM_PATH, + }, + }); +} diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts index 8a28200fb..9463ff98a 100644 --- a/src/lib/notifications/senders/__tests__/ntfy.test.ts +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -1,8 +1,8 @@ /** * ntfy sender unit tests. * - * Covers the outbound shape (topic appended to the server URL, priority and - * tag headers, bearer token), the outcome classification, and (#947) the + * Covers the outbound shape (a JSON publish to the server root carrying + * topic, priority and tags, bearer token), the outcome classification, and (#947) the * NOTIFICATION_PRIVATE_ORIGINS grant: a listed server rides the * operator-approved pin, an unlisted one keeps the public pin, and a listed * origin admits nothing beside itself. @@ -85,7 +85,7 @@ afterEach(() => { }); describe("sendViaNtfy", () => { - it("POSTs the topic URL through safeFetch under the public pin and returns ok on 200", async () => { + it("POSTs a JSON message to the server root through safeFetch under the public pin and returns ok on 200", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); const result = await sendViaNtfy(config, payload()); @@ -96,10 +96,17 @@ describe("sendViaNtfy", () => { { method?: string; headers?: Record; body?: string }, EgressOpts, ]; - expect(url).toBe("https://ntfy.example.com/health"); + expect(url).toBe("https://ntfy.example.com/"); expect(init.method).toBe("POST"); expect(init.headers?.Authorization).toBe("Bearer tk_secret"); - expect(init.body).toBe("Body"); + expect(init.headers?.["Content-Type"]).toBe("application/json"); + expect(JSON.parse(init.body!)).toEqual({ + topic: "health", + title: "Title", + message: "Body", + priority: 3, + tags: ["system-alert"], + }); expect(opts.requirePublicHost).toBe(true); expect(opts.operatorApprovedPrivateOrigin).toBeUndefined(); expect(recordPushAttemptMock).toHaveBeenCalledWith( @@ -164,7 +171,7 @@ describe("sendViaNtfy", () => { unknown, EgressOpts, ]; - expect(url).toBe("http://ntfy.lan:8080/health"); + expect(url).toBe("http://ntfy.lan:8080/"); expect(opts.requirePublicHost).toBe(false); expect(opts.operatorApprovedPrivateOrigin).toBe("http://ntfy.lan:8080"); expect(annotateMock).toHaveBeenCalledWith({ @@ -270,15 +277,19 @@ describe("sendViaNtfy — what the server said (#947)", () => { }); describe("sendViaNtfy — tap target and quick actions", () => { - function headersOfFirstCall(): Record { + function messageOfFirstCall(): Record { const [, init] = safeFetchMock.mock.calls[0] as [ string, - { headers: Record }, + { headers: Record; body: string }, ]; - return init.headers; + // Nothing of the message rides a header any more. + expect(Object.keys(init.headers).sort()).toEqual( + ["Authorization", "Content-Type"].sort(), + ); + return JSON.parse(init.body) as Record; } - it("sets Click to the resolved absolute tap URL", async () => { + it("sets click to the resolved absolute tap URL", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); await sendViaNtfy( config, @@ -291,17 +302,18 @@ describe("sendViaNtfy — tap target and quick actions", () => { }, }), ); - expect(headersOfFirstCall().Click).toBe("https://health.example/"); - expect(headersOfFirstCall().Actions).toBeUndefined(); + const message = messageOfFirstCall(); + expect(message.click).toBe("https://health.example/"); + expect(message.actions).toBeUndefined(); }); - it("omits Click when no public origin produced a URL", async () => { + it("omits click when no public origin produced a URL", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); await sendViaNtfy( config, payload({ tap: { path: "/", url: null, label: "Open" } }), ); - expect(headersOfFirstCall().Click).toBeUndefined(); + expect(messageOfFirstCall().click).toBeUndefined(); }); it("sends the quick answers as three POST http actions that clear the notification", async () => { @@ -317,18 +329,66 @@ describe("sendViaNtfy — tap target and quick actions", () => { ], }), ); - expect(headersOfFirstCall().Actions).toBe( - "http, Good, https://h.example/a/1, method=POST, clear=true; " + - "http, Okay, https://h.example/a/2, method=POST, clear=true; " + - "http, Bad, https://h.example/a/3, method=POST, clear=true", + expect(messageOfFirstCall().actions).toEqual([ + { + action: "http", + label: "Good", + url: "https://h.example/a/1", + method: "POST", + clear: true, + }, + { + action: "http", + label: "Okay", + url: "https://h.example/a/2", + method: "POST", + clear: true, + }, + { + action: "http", + label: "Bad", + url: "https://h.example/a/3", + method: "POST", + clear: true, + }, + ]); + }); + + it("carries non-ASCII titles and labels as plain UTF-8, URLs untouched", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await sendViaNtfy( + config, + payload({ + eventType: "MOOD_REMINDER", + title: "기분은 어떠세요?", + quickActions: [ + { id: "mood-good", label: "Świetnie", url: "https://h.example/a/1" }, + { id: "mood-okay", label: "보통", url: "https://h.example/a/2" }, + { id: "mood-bad", label: "Così così", url: "https://h.example/a/3" }, + ], + }), ); + const message = messageOfFirstCall(); + expect(message.title).toBe("기분은 어떠세요?"); + const actions = message.actions as { label: string; url: string }[]; + expect(actions.map((a) => a.label)).toEqual([ + "Świetnie", + "보통", + "Così così", + ]); + expect(actions.map((a) => a.url)).toEqual([ + "https://h.example/a/1", + "https://h.example/a/2", + "https://h.example/a/3", + ]); + expect(JSON.stringify(message)).not.toContain("=?UTF-8?"); }); }); -describe("ntfy header helpers", () => { - it("caps the actions at three and strips separators from labels", async () => { - const { buildNtfyActionsHeader } = await import("../ntfy"); - const header = buildNtfyActionsHeader({ +describe("ntfy message helpers", () => { + it("caps the actions at three and keeps labels verbatim", async () => { + const { buildNtfyActions } = await import("../ntfy"); + const actions = buildNtfyActions({ quickActions: [ { id: "mood-good", label: 'Go;od, "yes"', url: "https://h/1" }, { id: "mood-okay", label: "B", url: "https://h/2" }, @@ -336,20 +396,20 @@ describe("ntfy header helpers", () => { { id: "mood-bad", label: "D", url: "https://h/4" }, ], }); - expect(header?.split("; ")).toHaveLength(3); - expect(header).toContain("http, Go od yes, https://h/1"); - expect(header).not.toContain("https://h/4"); - expect(buildNtfyActionsHeader({ quickActions: [] })).toBeNull(); + expect(actions).toHaveLength(3); + // JSON needs no separator escaping: the label reaches ntfy as written. + expect(actions[0]!.label).toBe('Go;od, "yes"'); + expect(actions.map((a) => a.url)).not.toContain("https://h/4"); + expect(buildNtfyActions({ quickActions: [] })).toEqual([]); }); - it("RFC 2047-encodes a non-ASCII header value and leaves ASCII alone", async () => { - const { ntfyHeaderValue } = await import("../ntfy"); - expect(ntfyHeaderValue("Morning score 78")).toBe("Morning score 78"); - const encoded = ntfyHeaderValue("아침 점수 78"); - expect(encoded).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/); - const inner = encoded.slice("=?UTF-8?B?".length, -2); - expect(Buffer.from(inner, "base64").toString("utf8")).toBe("아침 점수 78"); - // `ü` is Latin-1, which fetch would send as one invalid UTF-8 byte. - expect(ntfyHeaderValue("Morgenüberblick")).toMatch(/^=\?UTF-8\?B\?/); + it("maps a medication reminder to high priority (4)", async () => { + const { buildNtfyMessage } = await import("../ntfy"); + expect( + buildNtfyMessage( + "t", + payload({ eventType: "MEDICATION_REMINDER" }) as never, + ).priority, + ).toBe(4); }); }); diff --git a/src/lib/notifications/senders/__tests__/urgency.test.ts b/src/lib/notifications/senders/__tests__/urgency.test.ts index a66b01831..29273456e 100644 --- a/src/lib/notifications/senders/__tests__/urgency.test.ts +++ b/src/lib/notifications/senders/__tests__/urgency.test.ts @@ -104,6 +104,15 @@ beforeEach(() => { afterEach(() => vi.clearAllMocks()); +/** The JSON message the ntfy sender published on call `i`. */ +function ntfyMessage(i: number): { + priority: number; + tags: string[]; + actions?: unknown; +} { + return JSON.parse((safeFetchMock.mock.calls[i][1] as { body: string }).body); +} + describe("ntfy urgent mapping", () => { it("urgent → Priority 5 (max) + warning tag", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); @@ -111,10 +120,8 @@ describe("ntfy urgent mapping", () => { { serverUrl: "https://ntfy.example.com", topic: "t" }, payload({ urgent: true }), ); - const [, init] = safeFetchMock.mock.calls[0]; - const headers = (init as { headers: Record }).headers; - expect(headers.Priority).toBe("5"); - expect(headers.Tags).toContain("warning"); + expect(ntfyMessage(0).priority).toBe(5); + expect(ntfyMessage(0).tags).toContain("warning"); }); it("non-urgent SYSTEM_ALERT → default priority, no warning tag", async () => { @@ -123,13 +130,8 @@ describe("ntfy urgent mapping", () => { { serverUrl: "https://ntfy.example.com", topic: "t" }, payload(), ); - const headers = ( - safeFetchMock.mock.calls[0][1] as { - headers: Record; - } - ).headers; - expect(headers.Priority).toBe("default"); - expect(headers.Tags).not.toContain("warning"); + expect(ntfyMessage(0).priority).toBe(3); + expect(ntfyMessage(0).tags).not.toContain("warning"); }); it("discreet urgent keeps the generic tag (no warning leak)", async () => { @@ -138,16 +140,11 @@ describe("ntfy urgent mapping", () => { { serverUrl: "https://ntfy.example.com", topic: "t" }, payload({ urgent: true, discreet: true }), ); - const headers = ( - safeFetchMock.mock.calls[0][1] as { - headers: Record; - } - ).headers; - expect(headers.Priority).toBe("5"); - expect(headers.Tags).toBe("reminder"); + expect(ntfyMessage(0).priority).toBe(5); + expect(ntfyMessage(0).tags).toEqual(["reminder"]); }); - it("does not send ntfy action headers to a managed Guardian", async () => { + it("does not send ntfy actions to a managed Guardian", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); await sendViaNtfy( { serverUrl: "https://ntfy.example.com", topic: "t" }, @@ -157,12 +154,7 @@ describe("ntfy urgent mapping", () => { recipientUserId: "guardian-1", }), ); - const headers = ( - safeFetchMock.mock.calls[0][1] as { - headers: Record; - } - ).headers; - expect(headers.Actions).toBeUndefined(); + expect(ntfyMessage(0).actions).toBeUndefined(); }); }); @@ -245,10 +237,7 @@ describe("APNs-less instance degrades gracefully", () => { expect(ntfy.ok).toBe(true); expect(web.ok).toBe(true); - expect( - (safeFetchMock.mock.calls[0][1] as { headers: Record }) - .headers.Priority, - ).toBe("5"); + expect(ntfyMessage(0).priority).toBe(5); expect( (generateRequestDetailsMock.mock.calls[0][2] as { urgency?: string }) .urgency, diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index 13b1fab78..96c905670 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -41,48 +41,24 @@ export async function sendViaNtfy( // can open. let policyReason: OriginReason | null = null; try { - const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`; - - // v1.18.4 — an explicitly urgent payload escalates to ntfy's top - // priority (`5` / max) so it bypasses the relay's batching and surfaces - // loudest; MEDICATION_REMINDER keeps its long-standing `high` (4); the - // rest stay `default` (3). `urgent` also adds a tag so the lock-screen - // entry reads as an alert (unless discreet privacy collapses tags). - const urgent = isUrgentPayload(payload) && payload.urgent === true; - const priority = urgent - ? "5" - : payload.eventType === "MEDICATION_REMINDER" - ? "high" - : "default"; - const eventTag = payload.discreet - ? "reminder" - : payload.eventType.toLowerCase().replace(/_/g, "-"); + // Published as JSON to the server root rather than as a body with + // headers to the topic URL. A header carries only Latin-1 (`fetch` + // refuses anything above U+00FF, and ntfy reads a Latin-1 byte as invalid + // UTF-8), so a Korean title or a Polish action label had to travel as an + // RFC 2047 encoded word, and an `Actions` header encoded whole, URLs and + // all, is not a form ntfy's action parser is documented to accept, so a + // reminder with an Italian, Korean or Polish label risked a 400 and never + // arrived. A JSON body is UTF-8 end to end and has no header encoding + // at all (https://docs.ntfy.sh/publish/#publish-as-json). + const url = `${config.serverUrl.replace(/\/$/, "")}/`; + const message = buildNtfyMessage(config.topic, payload); const headers: Record = { - Title: ntfyHeaderValue(plainPushText(payload.title, payload.eventType)), - Priority: priority, - // Discreet mode (cycle privacy): the X-Tags header is visible on the - // lock screen, so collapse it to a generic tag instead of leaking the - // cycle event name. An urgent (non-discreet) event also carries a - // `warning` tag so the entry renders as an alert. - Tags: urgent && !payload.discreet ? `warning,${eventTag}` : eventTag, + "Content-Type": "application/json", }; - if (config.authToken) { headers["Authorization"] = `Bearer ${config.authToken}`; } - - // Tapping the notification opens this URL (ntfy `Click`). Absent when - // the operator has not configured a public origin. - if (payload.tap?.url) { - headers["Click"] = payload.tap.url; - } - const actions = buildNtfyActionsHeader(payload); - if (actions) { - headers["Actions"] = ntfyHeaderValue(actions); - } - - // Strip HTML tags for ntfy (plain text only) + emoji on routine reminders - const body = plainPushText(stripHtml(payload.message), payload.eventType); + const body = JSON.stringify(message); // The DNS-rebinding pin (issue #217) sits on top of the input-time // guard enforced when the user saved the serverUrl; the authToken would @@ -201,47 +177,81 @@ export async function sendViaNtfy( /** ntfy shows at most three actions per notification. */ const NTFY_MAX_ACTIONS = 3; -/** - * A label safe inside the `Actions` header's short format: `,` and `;` - * separate fields and actions there, and a quote would open a quoted value. - */ -function ntfyActionLabel(label: string): string { - return label.replace(/[,;"'\r\n]/g, " ").trim() || "?"; +/** One `http` action button in ntfy's JSON form. */ +export interface NtfyHttpAction { + action: "http"; + label: string; + url: string; + method: "POST"; + clear: true; +} + +/** The JSON publish body (https://docs.ntfy.sh/publish/#publish-as-json). */ +export interface NtfyMessage { + topic: string; + title: string; + message: string; + priority: 3 | 4 | 5; + tags: string[]; + click?: string; + actions?: NtfyHttpAction[]; } /** - * The `Actions` header for the payload's quick answers, in ntfy's short - * format (https://docs.ntfy.sh/publish/#action-buttons): + * The message ntfy publishes for a payload. * - * http,