diff --git a/.env.example b/.env.example index 2f39edac0..2f200f2fd 100644 --- a/.env.example +++ b/.env.example @@ -18,15 +18,24 @@ # ----------------------------------------------------------------------------- # Database # ----------------------------------------------------------------------------- -# When using docker-compose.yml: the app container reaches Postgres via the -# service name "db", so the hostname is "db" — NOT "localhost". -# When developing without Docker (`pnpm dev` against a host-installed Postgres): -# change the hostname to "localhost" and adjust the credentials. -DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public" +# Bundled docker-compose.yml path (the common case): you do NOT set DATABASE_URL +# here. Compose CONSTRUCTS it inside the `app` service from POSTGRES_PASSWORD +# (see docker-compose.yml — the `environment:` block wins over any value in this +# file), so a DATABASE_URL line here is ignored for that stack. Set only +# POSTGRES_PASSWORD below and compose wires the connection string for you. +# +# Bare-Node / custom-compose path (`pnpm dev` against a host Postgres, or your +# own compose that does NOT derive DATABASE_URL): uncomment DATABASE_URL and set +# it explicitly. Hostname is "db" on the bundled compose network, or +# "localhost" for a host-installed Postgres. +# DATABASE_URL="postgresql://healthlog:YOUR_PASSWORD@localhost:5432/healthlog?schema=public" -# Postgres password — used both by the db service AND by DATABASE_URL above. -# Pick a strong random password, e.g. `openssl rand -hex 32`. -POSTGRES_PASSWORD="CHANGE-ME" +# Postgres password for the bundled `db` service — and the value compose splices +# into DATABASE_URL. The quick-start appends a generated one: +# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env +# Left commented here so that append does not create a duplicate key. Uncomment +# and set it directly instead if you prefer to edit the file by hand. +# POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32" # ----------------------------------------------------------------------------- diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 6e59c8a7f..c24150fd4 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -53,11 +53,14 @@ echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env | `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` | | `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token | -Then open `.env` and confirm `DATABASE_URL` includes the password you -just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`) -references the literal string `CHANGE-ME`; replace it with the value -you put in `POSTGRES_PASSWORD`, or rewrite the URL to substitute -explicitly. +You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml` +stack: compose constructs the connection string inside the `app` +service from `${POSTGRES_PASSWORD}` (its `environment:` block overrides +any `DATABASE_URL` in `.env`), so the `openssl` line above is the only +password step. `DATABASE_URL` only matters if you run a **bare Node** +process (`pnpm dev` against a host Postgres) or a **custom compose** that +does not derive it — in that case uncomment and set `DATABASE_URL` +explicitly in `.env` (see the commented example there). ## 3. Bring the stack up @@ -92,6 +95,22 @@ After the first user lands, you can: - Open `/measurements` and add a weight or blood pressure to confirm the data path is wired end-to-end. +> **Close public registration if you expose the instance to the +> internet.** Registration stays **open** after the first admin so you +> can invite the rest of your household — but on a publicly reachable +> host that means strangers can still sign up. Once everyone who needs +> an account has one, turn it off in **`/admin` → registration toggle** +> (or keep it open and hand out invite links instead). The admin +> settings show a reminder underneath the toggle while registration is +> open. + +> **No email is configured out of the box** — a deliberate first-boot +> simplicity win, but it also means password reset has no self-service +> path until you set SMTP (see `docs/self-hosting/notifications.md`). If +> you are the sole admin, **save your password in a manager now**: a +> locked-out solo admin with no SMTP recovers only via the CLI script in +> `docs/ops/password-reset.md`. + ## 5. Point the instance at a public hostname Local-only is fine for a single-user trial. For anything more — and diff --git a/messages/de.json b/messages/de.json index 8c9518cf2..251a01cae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2082,7 +2082,7 @@ "emptyAction": "Briefing erstellen", "loadingLabel": "Briefing wird zusammengestellt", "noProviderTitle": "Kein KI-Anbieter verbunden", - "noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.", + "noProviderDescription": "Richte HealthLog auf ein lokales Modell (Ollama, LM Studio – nichts verlässt dein Netzwerk) oder hinterlege deinen eigenen OpenAI- oder Anthropic-Schlüssel, und das tägliche Briefing fasst deine eigenen Messwerte in klarer Sprache zusammen – zum Beispiel: „Gewicht in zwei Wochen um 1,2 kg gesunken, Ruhepuls stabil bei 58, Blutdruck an 6 von 7 Tagen im Zielbereich.“ Die Einrichtung dauert etwa eine Minute.", "noProviderAction": "Zu den KI-Einstellungen", "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.", "refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.", @@ -5496,6 +5496,7 @@ "appSettingsDescription": "Globale Standardwerte für Registrierung, Sprache und Zeitzone neuer Konten.", "registrationEnabled": "Registrierung erlauben", "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Nutzer registrieren.", + "registrationOpenHint": "Die Registrierung ist offen — jeder, der diese Instanz erreichen kann, kann ein Konto anlegen. Schließe sie hier, sobald alle ein Konto haben.", "defaultLanguage": "Standard-Sprache", "defaultLanguageDescription": "Wird für neue Nutzer und als Fallback verwendet.", "defaultUserTimezone": "Standard-Zeitzone für neue Konten", diff --git a/messages/en.json b/messages/en.json index 3e0e5458c..e7c2b5281 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2082,7 +2082,7 @@ "emptyAction": "Generate briefing", "loadingLabel": "Synthesising today's briefing", "noProviderTitle": "No AI provider connected", - "noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.", + "noProviderDescription": "Point HealthLog at a local model (Ollama, LM Studio — nothing leaves your network) or bring your own OpenAI or Anthropic key, and the daily briefing turns your own readings into a plain-language summary — for example: “weight down 1.2 kg over two weeks, resting pulse steady at 58, blood pressure in range on 6 of 7 days.” Setup takes about a minute.", "noProviderAction": "Open AI settings", "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected.", "refreshFailedHint": "Couldn't refresh the briefing — showing the last one.", @@ -5496,6 +5496,7 @@ "appSettingsDescription": "Global defaults for registration, language, and the timezone of new accounts.", "registrationEnabled": "Allow registration", "registrationEnabledDescription": "When disabled, new users cannot register.", + "registrationOpenHint": "Registration is open — anyone who can reach this instance can create an account. Close it here once everyone who needs an account has one.", "defaultLanguage": "Default language", "defaultLanguageDescription": "Used for new users and as fallback.", "defaultUserTimezone": "Default timezone for new accounts", diff --git a/messages/es.json b/messages/es.json index b1a4fe79c..d5274da32 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2082,7 +2082,7 @@ "emptyAction": "Generar resumen", "loadingLabel": "Preparando el resumen", "noProviderTitle": "Ningún proveedor de IA conectado", - "noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.", + "noProviderDescription": "Apunta HealthLog a un modelo local (Ollama, LM Studio: nada sale de tu red) o usa tu propia clave de OpenAI o Anthropic, y el resumen diario convierte tus propias mediciones en un resumen en lenguaje claro; por ejemplo: «peso 1,2 kg menos en dos semanas, pulso en reposo estable en 58, tensión en rango 6 de 7 días». La configuración lleva alrededor de un minuto.", "noProviderAction": "Abrir ajustes de IA", "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado.", "refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.", @@ -5496,6 +5496,7 @@ "appSettingsDescription": "Valores globales por defecto para registro, idioma y zona horaria de cuentas nuevas.", "registrationEnabled": "Permitir el registro", "registrationEnabledDescription": "Cuando está desactivado, los nuevos usuarios no pueden registrarse.", + "registrationOpenHint": "El registro está abierto: cualquiera que pueda acceder a esta instancia puede crear una cuenta. Ciérralo aquí cuando todos tengan una cuenta.", "defaultLanguage": "Idioma predeterminado", "defaultLanguageDescription": "Se usa para los nuevos usuarios y como reserva.", "defaultUserTimezone": "Zona horaria predeterminada para cuentas nuevas", diff --git a/messages/fr.json b/messages/fr.json index cc4ca0e6c..3d5f7d347 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2082,7 +2082,7 @@ "emptyAction": "Générer le briefing", "loadingLabel": "Préparation du briefing", "noProviderTitle": "Aucun fournisseur d'IA connecté", - "noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.", + "noProviderDescription": "Faites pointer HealthLog vers un modèle local (Ollama, LM Studio — rien ne quitte votre réseau) ou utilisez votre propre clé OpenAI ou Anthropic, et le résumé quotidien transforme vos propres relevés en un résumé en langage clair — par exemple : « poids en baisse de 1,2 kg en deux semaines, pouls au repos stable à 58, tension dans la cible 6 jours sur 7 ». La configuration prend environ une minute.", "noProviderAction": "Ouvrir les réglages IA", "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté.", "refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.", @@ -5496,6 +5496,7 @@ "appSettingsDescription": "Valeurs globales par défaut pour l’inscription, la langue et le fuseau horaire des nouveaux comptes.", "registrationEnabled": "Autoriser l'inscription", "registrationEnabledDescription": "Lorsque cette option est désactivée, les nouveaux utilisateurs ne peuvent pas s'inscrire.", + "registrationOpenHint": "L'inscription est ouverte : toute personne pouvant accéder à cette instance peut créer un compte. Fermez-la ici une fois que tout le monde a un compte.", "defaultLanguage": "Langue par défaut", "defaultLanguageDescription": "Utilisé pour les nouveaux utilisateurs et comme solution de repli.", "defaultUserTimezone": "Fuseau horaire par défaut pour les nouveaux comptes", diff --git a/messages/it.json b/messages/it.json index 5c40b6fdf..ee46c3e30 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2082,7 +2082,7 @@ "emptyAction": "Genera briefing", "loadingLabel": "Preparazione del briefing", "noProviderTitle": "Nessun provider IA collegato", - "noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.", + "noProviderDescription": "Punta HealthLog verso un modello locale (Ollama, LM Studio: nulla lascia la tua rete) o usa la tua chiave OpenAI o Anthropic, e il riepilogo quotidiano trasforma le tue misurazioni in un riassunto in linguaggio chiaro, ad esempio: «peso in calo di 1,2 kg in due settimane, battito a riposo stabile a 58, pressione nel range 6 giorni su 7». La configurazione richiede circa un minuto.", "noProviderAction": "Apri impostazioni IA", "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato.", "refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.", @@ -5496,6 +5496,7 @@ "appSettingsDescription": "Impostazioni globali predefinite per registrazione, lingua e fuso orario dei nuovi account.", "registrationEnabled": "Consentire la registrazione", "registrationEnabledDescription": "Se disattivato, i nuovi utenti non possono registrarsi.", + "registrationOpenHint": "La registrazione è aperta: chiunque possa raggiungere questa istanza può creare un account. Chiudila qui quando tutti hanno un account.", "defaultLanguage": "Lingua predefinita", "defaultLanguageDescription": "Usata per i nuovi utenti e come ripiego.", "defaultUserTimezone": "Fuso orario predefinito per i nuovi account", diff --git a/messages/pl.json b/messages/pl.json index 3652ca629..da51aae35 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2082,7 +2082,7 @@ "emptyAction": "Wygeneruj briefing", "loadingLabel": "Przygotowanie briefingu", "noProviderTitle": "Brak połączonego dostawcy AI", - "noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.", + "noProviderDescription": "Skieruj HealthLog na model lokalny (Ollama, LM Studio — nic nie opuszcza Twojej sieci) lub podaj własny klucz OpenAI albo Anthropic, a codzienne podsumowanie zamieni Twoje pomiary w zrozumiały opis — na przykład: „waga niższa o 1,2 kg w dwa tygodnie, tętno spoczynkowe stabilne na 58, ciśnienie w normie przez 6 z 7 dni”. Konfiguracja zajmuje około minuty.", "noProviderAction": "Otwórz ustawienia AI", "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI.", "refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.", @@ -5496,6 +5496,7 @@ "appSettingsDescription": "Globalne ustawienia domyślne rejestracji, języka i strefy czasowej nowych kont.", "registrationEnabled": "Zezwól na rejestrację", "registrationEnabledDescription": "Gdy wyłączone, nowi użytkownicy nie mogą się zarejestrować.", + "registrationOpenHint": "Rejestracja jest otwarta — każdy, kto ma dostęp do tej instancji, może założyć konto. Zamknij ją tutaj, gdy wszyscy będą mieć konto.", "defaultLanguage": "Domyślny język", "defaultLanguageDescription": "Używany dla nowych użytkowników i jako rozwiązanie zapasowe.", "defaultUserTimezone": "Domyślna strefa czasowa dla nowych kont", diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index b663f67c3..a1bb577a0 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -8,11 +8,23 @@ vi.mock("@/lib/api-handler", () => ({ })), })); -vi.mock("@/lib/db", () => ({ - prisma: { - measurement: { create: vi.fn(), upsert: vi.fn() }, - }, -})); +vi.mock("@/lib/db", () => { + const measurement = { + findMany: vi.fn(), + createMany: vi.fn(), + updateMany: vi.fn(), + }; + return { + prisma: { + measurement, + // Run the batched write callback against the same measurement mock so + // createMany / updateMany calls are observable. + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => + fn({ measurement }), + ), + }, + }; +}); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), @@ -83,6 +95,23 @@ beforeEach(() => { remaining: 4, resetAt: new Date(Date.now() + 1000), } as never); + // Default: nothing pre-exists; the transaction runner is re-stubbed by the + // db mock factory (resetAllMocks clears its implementation), so restore it. + const measurement = prisma.measurement as unknown as { + findMany: ReturnType; + createMany: ReturnType; + updateMany: ReturnType; + }; + measurement.findMany.mockResolvedValue([]); + measurement.createMany.mockResolvedValue({ count: 0 }); + measurement.updateMany.mockResolvedValue({ count: 0 }); + ( + prisma.$transaction as unknown as { + mockImplementation: (f: unknown) => void; + } + ).mockImplementation(async (fn: (tx: unknown) => unknown) => + fn({ measurement }), + ); }); describe("POST /api/import/csv — rate limit", () => { @@ -103,18 +132,23 @@ describe("POST /api/import/csv — rate limit", () => { }); }); +const mMeasurement = () => + prisma.measurement as unknown as { + findMany: ReturnType; + createMany: ReturnType; + updateMany: ReturnType; + }; + describe("POST /api/import/csv — fatal header error", () => { it("returns 422 when a required column is missing", async () => { const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg")); expect(res.status).toBe(422); - expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled(); + expect(mMeasurement().createMany).not.toHaveBeenCalled(); }); }); -describe("POST /api/import/csv — write loop + per-row envelope", () => { +describe("POST /api/import/csv — batched write + per-row envelope", () => { it("inserts valid rows, skips invalid ones, returns per-row status", async () => { - vi.mocked(prisma.measurement.create).mockResolvedValue({} as never); - const res = await POST( csvRequest( [ @@ -135,12 +169,29 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => { const reasons = body.data?.rows.map((r) => r.reason); expect(reasons).toContain("unknown_type"); expect(reasons).toContain("missing_timezone_offset"); - // One create call for the single valid row. - expect(vi.mocked(prisma.measurement.create)).toHaveBeenCalledTimes(1); + // One bulk createMany carrying the single valid survivor. + expect(mMeasurement().createMany).toHaveBeenCalledTimes(1); + const arg = mMeasurement().createMany.mock.calls[0][0]; + expect(arg.data).toHaveLength(1); // Rollup re-fold fired for the touched (type, day). expect(vi.mocked(recomputeBucketsForMeasurement)).toHaveBeenCalled(); }); + it("batches many rows into a single createMany (no per-row round-trip)", async () => { + const rows = [HEADER]; + for (let i = 0; i < 50; i++) { + const mm = String(i).padStart(2, "0"); // unique minute → unique measuredAt + rows.push(`WEIGHT,${80 + i * 0.1},kg,2026-05-01T08:${mm}:00Z,,,`); + } + const res = await POST(csvRequest(rows.join("\n"))); + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(50); + // 50 rows, one createMany call — the whole point of the batch. + expect(mMeasurement().createMany).toHaveBeenCalledTimes(1); + expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(50); + }); + it("dryRun previews without writing and reports projected inserts", async () => { const res = await POST( csvRequest( @@ -153,17 +204,14 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => { expect(body.data?.dryRun).toBe(true); expect(body.data?.inserted).toBe(1); expect(body.data?.rows[0].status).toBe("inserted"); - expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled(); + expect(mMeasurement().createMany).not.toHaveBeenCalled(); expect(vi.mocked(recomputeBucketsForMeasurement)).not.toHaveBeenCalled(); }); - it("upserts an externalId row and surfaces it as updated when it already existed", async () => { - const created = new Date("2026-05-01T08:00:00Z"); - const bumped = new Date("2026-05-02T08:00:00Z"); - vi.mocked(prisma.measurement.upsert).mockResolvedValue({ - createdAt: created, - updatedAt: bumped, - } as never); + it("surfaces an externalId row as updated when it already existed (updateMany)", async () => { + mMeasurement().findMany.mockResolvedValue([ + { type: "WEIGHT", externalId: "ext-1" }, + ]); const res = await POST( csvRequest( @@ -174,13 +222,28 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => { expect(body.data?.updated).toBe(1); expect(body.data?.inserted).toBe(0); expect(body.data?.rows[0].status).toBe("updated"); - expect(vi.mocked(prisma.measurement.upsert)).toHaveBeenCalledTimes(1); + expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1); + expect(mMeasurement().createMany).not.toHaveBeenCalled(); }); - it("counts a unique-constraint duplicate as skipped/duplicate", async () => { - vi.mocked(prisma.measurement.create).mockRejectedValue( - new Error("Unique constraint failed"), + it("inserts an externalId row when it does not exist yet", async () => { + mMeasurement().findMany.mockResolvedValue([]); + const res = await POST( + csvRequest( + [HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,ext-2"].join("\n"), + ), ); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.updated).toBe(0); + expect(body.data?.rows[0].status).toBe("inserted"); + expect(mMeasurement().createMany).toHaveBeenCalledTimes(1); + }); + + it("counts a pre-existing natural-key row as skipped/duplicate", async () => { + mMeasurement().findMany.mockResolvedValue([ + { type: "WEIGHT", measuredAt: new Date("2026-05-01T08:00:00Z") }, + ]); const res = await POST( csvRequest([HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n")), @@ -192,5 +255,23 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => { status: "skipped", reason: "duplicate", }); + expect(mMeasurement().createMany).not.toHaveBeenCalled(); + }); + + it("collapses an in-file duplicate (same type+measuredAt) to one insert + one duplicate", async () => { + const res = await POST( + csvRequest( + [ + HEADER, + "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,", + "WEIGHT,80.6,kg,2026-05-01T08:00:00Z,,,", + ].join("\n"), + ), + ); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.skipped).toBe(1); + // Only the first survivor reaches the bulk insert. + expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(1); }); }); diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index ae6a53de9..7fdd868d7 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -19,6 +19,8 @@ import type { MeasurementType, GlucoseContext, } from "@/generated/prisma/client"; +import { Prisma } from "@/generated/prisma/client"; +import type { NormalisedMeasurementRow } from "@/lib/import/csv-measurements"; /** * CSV measurement import (v1.17.1). @@ -106,84 +108,165 @@ export const POST = apiHandler(async (request: NextRequest) => { const writeOutcome = new Map(); if (!dryRun) { - for (const result of okRows) { - const m = result.row; - try { - if (m.externalId) { - // Idempotent re-import keyed on the source-stable id — re-uploading - // the same file updates in place rather than minting duplicates. - const res = await prisma.measurement.upsert({ + // Batched write. The previous shape ran one serial `upsert` (or `create`) + // per accepted row — N round-trips for an N-row file, which on a + // few-thousand-row import approaches the request timeout. This adopts the + // fast shape `/measurements/batch` uses: pre-fetch the existing rows under + // the IMPORT source in one query per dedup-key shape, partition into bulk + // insert / update sets, then `createMany` + grouped `updateMany`. The + // per-line inserted/updated/duplicate envelope is reconstructed from the + // partition, so the response contract is unchanged. + + const buildCreateData = ( + m: NormalisedMeasurementRow, + ): Prisma.MeasurementCreateManyInput => ({ + userId, + type: m.type as MeasurementType, + value: m.value, + unit: m.unit, + source: "IMPORT", + ...(m.externalId ? { externalId: m.externalId } : {}), + measuredAt: m.measuredAt, + notes: null, + notesEncrypted: encryptNote(m.notes ?? null), + glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null, + }); + + const extRows = okRows.filter((r) => r.row.externalId); + const plainRows = okRows.filter((r) => !r.row.externalId); + + // --- externalId rows: idempotent upsert on + // (userId, type, source=IMPORT, externalId). One probe fetches every + // pre-existing key; a hit means the row updates in place. + const extExisting = + extRows.length > 0 + ? await prisma.measurement.findMany({ where: { - userId_type_source_externalId: { - userId, - type: m.type as MeasurementType, - source: "IMPORT", - externalId: m.externalId, - }, - }, - update: { - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt, - notes: null, - notesEncrypted: encryptNote(m.notes ?? null), - glucoseContext: - (m.glucoseContext as GlucoseContext | undefined) ?? null, - }, - create: { userId, - type: m.type as MeasurementType, - value: m.value, - unit: m.unit, source: "IMPORT", - externalId: m.externalId, - measuredAt: m.measuredAt, - notes: null, - notesEncrypted: encryptNote(m.notes ?? null), - glucoseContext: - (m.glucoseContext as GlucoseContext | undefined) ?? null, + OR: extRows.map((r) => ({ + type: r.row.type as MeasurementType, + externalId: r.row.externalId as string, + })), }, - select: { createdAt: true, updatedAt: true }, - }); - // A fresh create has createdAt === updatedAt; an in-place update - // bumps updatedAt past createdAt. - if (res.updatedAt.getTime() > res.createdAt.getTime()) { - updated++; - writeOutcome.set(result.line, "updated"); - } else { - inserted++; - writeOutcome.set(result.line, "inserted"); - } - } else { - await prisma.measurement.create({ - data: { + select: { type: true, externalId: true }, + }) + : []; + const extExistingSet = new Set( + extExisting.map((e) => `${e.type}::${e.externalId}`), + ); + + // Partition in file order, honouring in-file repeats: the first sighting + // of a brand-new key inserts, every later sighting (or any sighting of a + // key already in the DB) updates in place. The last value for a key wins, + // matching the old sequential-upsert overwrite. Insert/update maps are + // keyed by (type, externalId) so a repeated key collapses to one write. + const seenExt = new Set(); + const extInserts = new Map(); + const extUpdates = new Map(); + for (const result of extRows) { + const m = result.row; + const key = `${m.type}::${m.externalId}`; + if (extExistingSet.has(key)) { + extUpdates.set(key, m); + updated++; + writeOutcome.set(result.line, "updated"); + } else if (seenExt.has(key)) { + // New-to-DB key seen again in this file: still a single create with + // the latest value, but this line reads as an update just as the + // sequential upsert did. + extInserts.set(key, m); + updated++; + writeOutcome.set(result.line, "updated"); + } else { + seenExt.add(key); + extInserts.set(key, m); + inserted++; + writeOutcome.set(result.line, "inserted"); + } + touchedMeasurements.push({ + type: m.type as MeasurementType, + measuredAt: m.measuredAt, + }); + } + + // --- externalId-less rows: create-only, deduped on the natural unique key + // (userId, type, measuredAt, source=IMPORT, sleepStage=null). A collision + // — against the DB or an earlier row in this same file — is a duplicate, + // never an overwrite (each sample is a canonical reading). + const plainExisting = + plainRows.length > 0 + ? await prisma.measurement.findMany({ + where: { userId, - type: m.type as MeasurementType, - value: m.value, - unit: m.unit, source: "IMPORT", - measuredAt: m.measuredAt, - notes: null, - notesEncrypted: encryptNote(m.notes ?? null), - glucoseContext: - (m.glucoseContext as GlucoseContext | undefined) ?? null, + OR: plainRows.map((r) => ({ + type: r.row.type as MeasurementType, + measuredAt: r.row.measuredAt, + })), }, - }); - inserted++; - writeOutcome.set(result.line, "inserted"); - } + select: { type: true, measuredAt: true }, + }) + : []; + const plainExistingSet = new Set( + plainExisting.map((e) => `${e.type}::${e.measuredAt.getTime()}`), + ); + const seenPlain = new Set(); + const plainInserts: NormalisedMeasurementRow[] = []; + for (const result of plainRows) { + const m = result.row; + const key = `${m.type}::${m.measuredAt.getTime()}`; + if (plainExistingSet.has(key) || seenPlain.has(key)) { + skipped++; + writeOutcome.set(result.line, "duplicate"); + } else { + seenPlain.add(key); + plainInserts.push(m); + inserted++; + writeOutcome.set(result.line, "inserted"); touchedMeasurements.push({ type: m.type as MeasurementType, measuredAt: m.measuredAt, }); - } catch { - // Unique-constraint violation (a NULL-externalId duplicate against an - // existing row) — count as skipped rather than failing the file. - skipped++; - writeOutcome.set(result.line, "duplicate"); } } + // Bulk write: createMany the survivors (chunked under the PG parameter + // cap, skipDuplicates to absorb a concurrent double-submit race), then + // grouped updateMany for the pre-existing externalId rows. + const toCreate: Prisma.MeasurementCreateManyInput[] = [ + ...[...extInserts.values()].map(buildCreateData), + ...plainInserts.map(buildCreateData), + ]; + const CREATE_CHUNK = 200; + await prisma.$transaction(async (tx) => { + for (let i = 0; i < toCreate.length; i += CREATE_CHUNK) { + await tx.measurement.createMany({ + data: toCreate.slice(i, i + CREATE_CHUNK), + skipDuplicates: true, + }); + } + for (const m of extUpdates.values()) { + await tx.measurement.updateMany({ + where: { + userId, + source: "IMPORT", + type: m.type as MeasurementType, + externalId: m.externalId as string, + }, + data: { + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + notes: null, + notesEncrypted: encryptNote(m.notes ?? null), + glucoseContext: + (m.glucoseContext as GlucoseContext | undefined) ?? null, + }, + }); + } + }); + // One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV // pays at most ~N (type, day) recomputes, not 10 000 per-row hooks. // Best-effort: a populator hiccup never fails the importer. diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index ff0edadc8..9bcfb193f 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -13,18 +13,16 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; /** - * v1.4.25 W14b — onboarding wizard step page (foundation scaffold). + * Onboarding wizard step page. * * Routes: * /onboarding/0 welcome → carousel / value-prop intro * /onboarding/1 goals → "what do you want to track?" - * /onboarding/2 source → Withings / Apple Health / manual cards + * /onboarding/2 source → device/import source cards (see + * source-card-grid.tsx for the shipped set) * /onboarding/3 baseline → first measurement or sync confirmation * /onboarding/4 done → success screen + return to dashboard * - * This page ships the scaffold only — the step body is a placeholder - * `
` that the W14b-Content agent replaces with real step UI. - * * Step gating: * - Unauthenticated → `/auth/login` (the proxy also enforces this, * but the server-side check keeps the contract explicit). @@ -139,10 +137,12 @@ export default async function OnboardingStepPage({ params }: PageProps) { ); } - // Source (step 2) — four-card grid. Manual is the implicit default; - // Withings opens OAuth in a new tab; Apple Health is rendered as a - // "coming with v1.5" announce card; Garmin is omitted per the - // W14b-Content brief (not on the roadmap yet). + // Source (step 2) — card grid. Manual is the implicit default; the + // connectable sources (Apple Health, WHOOP, Oura, Polar, Nightscout, + // Fitbit/Pixel via Google Health) live in `source-card-grid.tsx`'s + // SOURCE_CARDS / MORE_SOURCES lists — refer to that component for the + // current shipped set rather than duplicating it here. The selection + // is non-binding: nothing here auto-configures a sync. if (requested === 2) { return ( diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 84bda6238..ff6a671cf 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -96,6 +96,19 @@ export function GeneralSettingsSection() { disabled={updateSettings.isPending} /> + {/* Honest nudge: registration stays open after the first admin so a + household can be invited, but on an internet-exposed instance that + means anyone who can reach the URL can sign up. Surface a quiet + reminder while it is open — no behaviour change, just a signal. */} + {(settings?.registrationEnabled ?? true) && ( +

+ {t("admin.registrationOpenHint")} +

+ )} + {/* v1.23 — instance-wide "require a second factor" policy. Every account without an active factor is sent to forced enrollment after sign-in. This is a web-session nudge, not an API wall: direct API / diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index a4557b5c0..2368e1962 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -15,6 +15,7 @@ import { import { useTranslations } from "@/lib/i18n/context"; import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; import { cn } from "@/lib/utils"; +import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; /** * v1.4.32 — ``. @@ -70,7 +71,7 @@ function formatDuration(seconds: number): string { function formatDayLabel(iso: string, locale: string): string { const d = new Date(iso); - return new Intl.DateTimeFormat(locale, { + return getDateTimeFormat(locale, { weekday: "short", day: "2-digit", month: "short", diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx index 4087e4bd3..6ca8783df 100644 --- a/src/components/insights/workout-detail.tsx +++ b/src/components/insights/workout-detail.tsx @@ -17,6 +17,7 @@ import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { cn } from "@/lib/utils"; +import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; import { hourCycleOptions, @@ -73,7 +74,7 @@ function formatDuration(seconds: number): string { function formatDistanceKm(meters: number, locale: string): string { const km = meters / 1000; - return new Intl.NumberFormat(locale, { + return getNumberFormat(locale, { maximumFractionDigits: 2, minimumFractionDigits: km < 10 ? 2 : 1, }).format(km); @@ -84,7 +85,7 @@ function formatNumber( locale: string, fractionDigits = 0, ): string { - return new Intl.NumberFormat(locale, { + return getNumberFormat(locale, { maximumFractionDigits: fractionDigits, minimumFractionDigits: fractionDigits, }).format(value); @@ -111,13 +112,13 @@ function formatDateRange( ): string { const start = new Date(startedAt); const end = new Date(endedAt); - const dateFmt = new Intl.DateTimeFormat(locale, { + const dateFmt = getDateTimeFormat(locale, { weekday: "long", day: "2-digit", month: "long", year: "numeric", }); - const timeFmt = new Intl.DateTimeFormat(locale, { + const timeFmt = getDateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", ...hourCycleOptions(timeFormat), diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 3f17d9fb5..712aaf77b 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; +import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; import { hourCycleOptions, @@ -68,14 +69,14 @@ function formatDuration(seconds: number): string { function formatDistanceKm(meters: number, locale: string): string { const km = meters / 1000; - return new Intl.NumberFormat(locale, { + return getNumberFormat(locale, { maximumFractionDigits: 2, minimumFractionDigits: km < 10 ? 2 : 1, }).format(km); } function formatEnergy(kcal: number, locale: string): string { - return new Intl.NumberFormat(locale, { + return getNumberFormat(locale, { maximumFractionDigits: 0, }).format(Math.round(kcal)); } @@ -86,7 +87,7 @@ function formatDate( timeFormat: TimeFormatPreference, ): string { const d = new Date(iso); - return new Intl.DateTimeFormat(locale, { + return getDateTimeFormat(locale, { weekday: "short", day: "2-digit", month: "short", diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 7f0084300..8c52a5091 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -10,6 +10,7 @@ import { MedicationCardBody } from "@/components/medications/card-parts/medicati import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { formatDateTime, formatTime } from "@/lib/format"; +import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { formatDose } from "@/lib/medications/format-dose"; import { @@ -335,7 +336,7 @@ export function MedicationCard({ // Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and // string-comparable for the today / yesterday / older bucketing below. // The actual user-facing display goes through formatTime / formatDateTime. - const dayFormatter = new Intl.DateTimeFormat("en-CA", { + const dayFormatter = getDateTimeFormat("en-CA", { timeZone: userTz, year: "numeric", month: "2-digit", diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 04f1eff50..1fd1fb5cf 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -12,6 +12,7 @@ import { useContext, useEffect, useState, + useSyncExternalStore, type ReactNode, } from "react"; import { I18nProvider, useTranslations } from "@/lib/i18n/context"; @@ -63,59 +64,91 @@ function applyTheme(resolved: "dark" | "light") { document.documentElement.classList.add(resolved); } +// ── Theme as an external store ─────────────────────── +// +// The stored preference lives in localStorage, and the OS preference in +// matchMedia — both browser-only values that must NOT be read in a `useState` +// initializer (server renders "dark", a client with a stored "light"/"system" +// initializes differently → the React #418 hydration seam this repo already +// hit with the sidebar). `useSyncExternalStore` is the sanctioned fix: it reads +// the value with an SSR-stable server snapshot ("dark") and reconciles to the +// real value after hydration without a setState-in-effect. The nonce-bound +// inline script in layout.tsx stamps the correct class pre-paint, so there is +// no visual FOUC while React reconciles. + +const THEME_STORAGE_KEY = "healthlog-theme"; +const themeListeners = new Set<() => void>(); + +function notifyThemeListeners() { + for (const cb of themeListeners) cb(); +} + +function subscribeStoredTheme(onChange: () => void): () => void { + themeListeners.add(onChange); + const onStorage = (e: StorageEvent) => { + if (e.key === THEME_STORAGE_KEY) onChange(); + }; + window.addEventListener("storage", onStorage); + return () => { + themeListeners.delete(onChange); + window.removeEventListener("storage", onStorage); + }; +} + +function getStoredThemeSnapshot(): Theme { + const saved = localStorage.getItem(THEME_STORAGE_KEY) as Theme | null; + // A stored "light"/"dark"/"system" choice wins. With no stored preference the + // app defaults to dark rather than tracking the OS. + if (saved === "light" || saved === "dark" || saved === "system") return saved; + return "dark"; +} + +function getServerThemeSnapshot(): Theme { + return "dark"; +} + +function subscribeSystemTheme(onChange: () => void): () => void { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); +} + +function getSystemThemeSnapshot(): "dark" | "light" { + return getSystemTheme(); +} + +function getServerSystemSnapshot(): "dark" | "light" { + return "dark"; +} + function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setThemeState] = useState(() => { - if (typeof window === "undefined") return "dark"; - const saved = localStorage.getItem("healthlog-theme") as Theme | null; - // A stored "light"/"dark"/"system" choice wins. With no stored - // preference the app defaults to dark rather than tracking the OS. - if (saved === "light" || saved === "dark") return saved; - if (saved === "system") return "system"; - return "dark"; - }); + const theme = useSyncExternalStore( + subscribeStoredTheme, + getStoredThemeSnapshot, + getServerThemeSnapshot, + ); + const systemTheme = useSyncExternalStore( + subscribeSystemTheme, + getSystemThemeSnapshot, + getServerSystemSnapshot, + ); + const resolvedTheme = theme === "system" ? systemTheme : theme; - const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() => { - if (typeof window === "undefined") return "dark"; - const saved = localStorage.getItem("healthlog-theme") as Theme | null; - if (saved === "light" || saved === "dark") return saved; - if (saved === "system") return getSystemTheme(); - return "dark"; - }); - - // Apply theme on mount (inline script handles FOUC, this ensures classes are in sync) + // Keep the class in sync with the resolved theme. This is a DOM write, + // not a setState — it runs on mount (reconciling the class the inline script + // set to any stored preference) and whenever the resolved theme changes. useEffect(() => { applyTheme(resolvedTheme); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Listen for OS preference changes when in system mode - useEffect(() => { - if (theme !== "system") return; - const mq = window.matchMedia("(prefers-color-scheme: dark)"); - const handler = (e: MediaQueryListEvent) => { - const resolved = e.matches ? "dark" : "light"; - setResolvedTheme(resolved); - applyTheme(resolved); - }; - mq.addEventListener("change", handler); - return () => mq.removeEventListener("change", handler); - }, [theme]); + }, [resolvedTheme]); const setTheme = useCallback((next: Theme) => { - setThemeState(next); // Persist every explicit choice, including "system" — the absence of a - // stored value is reserved for a fresh visitor and now defaults to dark. + // stored value is reserved for a fresh visitor and defaults to dark. // Storing "system" verbatim keeps an explicit OS-tracking choice - // distinguishable from "never chose", so a reload honours it. - localStorage.setItem("healthlog-theme", next); - if (next === "system") { - const resolved = getSystemTheme(); - setResolvedTheme(resolved); - applyTheme(resolved); - } else { - setResolvedTheme(next); - applyTheme(next); - } + // distinguishable from "never chose", so a reload honours it. The write + + // notify re-reads the external store; the effect above applies the class. + localStorage.setItem(THEME_STORAGE_KEY, next); + notifyThemeListeners(); }, []); return ( diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 77ab68ae8..512e1afbf 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -3,6 +3,18 @@ import type { Instrumentation } from "next"; export async function register() { // Only start the worker on the Node.js server runtime (not Edge, not build) if (process.env.NEXT_RUNTIME === "nodejs") { + // First-boot readiness summary: a human-readable block on the core secrets + // (with the exact fix on a bad one) before any fail-closed loader throws, + // plus a one-glance status of the optional channels. Runs for every process + // type (web + worker). Never throws — the real gate stays the loaders. + try { + const { logReadinessSummary } = + await import("@/lib/boot/readiness-summary"); + logReadinessSummary(); + } catch { + // A readiness summary must never break boot. + } + // Every Node process watches its own event loop — web-only // containers included; a stall report from the process serving // requests is the whole point. diff --git a/src/lib/auth/__tests__/secure-cookie.test.ts b/src/lib/auth/__tests__/secure-cookie.test.ts index e56d5645e..c329851ab 100644 --- a/src/lib/auth/__tests__/secure-cookie.test.ts +++ b/src/lib/auth/__tests__/secure-cookie.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -import { shouldEmitSecureCookie } from "../secure-cookie"; +import { + shouldEmitSecureCookie, + detectInsecureCookieTransport, +} from "../secure-cookie"; + +function req(headers: Record, url = "http://10.0.0.5:3000/x") { + return { headers: new Headers(headers), url }; +} describe("shouldEmitSecureCookie", () => { afterEach(() => { @@ -47,3 +54,56 @@ describe("shouldEmitSecureCookie", () => { expect(shouldEmitSecureCookie()).toBe(false); }); }); + +describe("detectInsecureCookieTransport", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns null when the Secure flag is off (SESSION_COOKIE_SECURE=false)", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", "false"); + expect(detectInsecureCookieTransport(req({}))).toBeNull(); + }); + + it("warns when a Secure cookie will be set on a plain-HTTP request (no proxy)", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + const msg = detectInsecureCookieTransport(req({})); + expect(msg).toContain("SESSION_COOKIE_SECURE=false"); + }); + + it("returns null behind a TLS proxy that forwards X-Forwarded-Proto: https", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + expect( + detectInsecureCookieTransport(req({ "x-forwarded-proto": "https" })), + ).toBeNull(); + }); + + it("reads the left-most entry of a comma-separated proto chain", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + expect( + detectInsecureCookieTransport( + req({ "x-forwarded-proto": "https, http" }), + ), + ).toBeNull(); + }); + + it("warns when the proxy forwards X-Forwarded-Proto: http explicitly", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + expect( + detectInsecureCookieTransport(req({ "x-forwarded-proto": "http" })), + ).not.toBeNull(); + }); + + it("returns null when the request URL is already https", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + expect( + detectInsecureCookieTransport(req({}, "https://healthlog.example.com/x")), + ).toBeNull(); + }); +}); diff --git a/src/lib/auth/login-response.ts b/src/lib/auth/login-response.ts index d0998afd8..d9330dd83 100644 --- a/src/lib/auth/login-response.ts +++ b/src/lib/auth/login-response.ts @@ -27,6 +27,8 @@ import { } from "@/lib/auth/native-client"; import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; import { recordSignInDevice } from "@/lib/auth/login-alert"; +import { detectInsecureCookieTransport } from "@/lib/auth/secure-cookie"; +import { annotate } from "@/lib/logging/context"; import type { User } from "@/generated/prisma/client"; export interface FinishLoginParams { @@ -116,8 +118,20 @@ export async function finishLogin( }); } - // Browser path — session cookie. `createSession` anchors the onboarding - // cookie itself; thread the user's onboarding state through. + // Browser path — session cookie. Before minting it, surface the single + // most common self-host snag: a Secure cookie about to be set on a plain-HTTP + // request drops silently and loops the login. Warn-log + wide-event so an + // operator sees the cause; the login still completes. + const transportWarning = detectInsecureCookieTransport(request); + if (transportWarning) { + console.warn( + `[auth] session-cookie transport mismatch: ${transportWarning}`, + ); + annotate({ meta: { session_cookie_secure_transport_mismatch: true } }); + } + + // `createSession` anchors the onboarding cookie itself; thread the user's + // onboarding state through. await createSession( user.id, user.onboardingCompletedAt == null, diff --git a/src/lib/auth/secure-cookie.ts b/src/lib/auth/secure-cookie.ts index c2fb8b931..d200b9af0 100644 --- a/src/lib/auth/secure-cookie.ts +++ b/src/lib/auth/secure-cookie.ts @@ -28,3 +28,57 @@ export function shouldEmitSecureCookie(): boolean { if (override === "true") return true; return process.env.NODE_ENV === "production"; } + +/** + * One-line diagnostic for the single most common self-host login-loop cause: + * the app is about to set a `Secure` session cookie + * (`shouldEmitSecureCookie() === true`) while the request itself arrived over + * plain HTTP. The browser silently drops a `Secure` cookie on an `http://` + * origin, so the next request is unauthenticated and the user bounces back to + * the login page with no error at all — it reads like a wrong password forever. + * + * Returns a human-readable message when the mismatch is detected, else `null`. + * The caller logs it at login time (`console.warn` survives the prod + * `console.*` strip) so an operator sees WHY login loops without having to find + * the note buried in three docs. Non-fatal: the login still completes; the + * cookie just won't stick until `SESSION_COOKIE_SECURE=false` is set (or TLS is + * fronted). + * + * A request counts as HTTPS when `x-forwarded-proto` names https (the shape + * every documented TLS reverse proxy sets) OR the request URL is already + * https. A proxy that terminates TLS but omits `x-forwarded-proto` can produce + * a benign false positive — the message names that case so it stays actionable + * either way. + */ +export function detectInsecureCookieTransport(req: { + headers: Headers; + url: string; +}): string | null { + if (!shouldEmitSecureCookie()) return null; + + // `x-forwarded-proto` can be a comma-separated proto chain; the left-most + // entry is the client-facing scheme the browser actually spoke. + const clientProto = req.headers + .get("x-forwarded-proto") + ?.split(",")[0] + ?.trim() + .toLowerCase(); + if (clientProto === "https") return null; + + let urlIsHttps = false; + try { + urlIsHttps = new URL(req.url).protocol === "https:"; + } catch { + urlIsHttps = false; + } + if (urlIsHttps) return null; + + return ( + "This request arrived over plain HTTP but the session cookie is being " + + "issued with the `Secure` flag — the browser will drop it and login will " + + "loop back to the sign-in page with no error message. If you serve HTTP " + + "directly (LAN / NAS / Tailscale, no TLS reverse proxy), set " + + "SESSION_COOKIE_SECURE=false. If a reverse proxy terminates TLS in front " + + "of this container, make sure it forwards `X-Forwarded-Proto: https`." + ); +} diff --git a/src/lib/boot/__tests__/readiness-summary.test.ts b/src/lib/boot/__tests__/readiness-summary.test.ts new file mode 100644 index 000000000..4d6211ba2 --- /dev/null +++ b/src/lib/boot/__tests__/readiness-summary.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; + +import { + collectReadiness, + formatReadiness, + logReadinessSummary, +} from "../readiness-summary"; + +const HEX32 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +function baseEnv(): Record { + return { + ENCRYPTION_KEY: HEX32, + API_TOKEN_HMAC_KEY: HEX32, + DATABASE_URL: "postgresql://healthlog:pw@db:5432/healthlog", + NODE_ENV: "production", + }; +} + +function lineFor(env: Record, label: string) { + return collectReadiness(env).lines.find((l) => l.label === label); +} + +describe("collectReadiness — core secrets", () => { + it("reports no blocker when the three core secrets are valid", () => { + const report = collectReadiness(baseEnv()); + expect(report.hasBlocker).toBe(false); + expect(lineFor(baseEnv(), "ENCRYPTION_KEY")?.status).toBe("ok"); + expect(lineFor(baseEnv(), "API_TOKEN_HMAC_KEY")?.status).toBe("ok"); + expect(lineFor(baseEnv(), "DATABASE_URL")?.status).toBe("ok"); + }); + + it("flags a missing ENCRYPTION_KEY as a blocker with the openssl fix", () => { + const env = baseEnv(); + delete env.ENCRYPTION_KEY; + const report = collectReadiness(env); + expect(report.hasBlocker).toBe(true); + const line = report.lines.find((l) => l.label === "ENCRYPTION_KEY"); + expect(line?.status).toBe("error"); + expect(line?.detail).toContain("openssl rand -hex 32"); + }); + + it("flags a too-short ENCRYPTION_KEY as a blocker", () => { + const env = baseEnv(); + env.ENCRYPTION_KEY = "deadbeef"; + expect(collectReadiness(env).hasBlocker).toBe(true); + }); + + it("accepts the ENCRYPTION_KEYS rotation map instead of the single key", () => { + const env = baseEnv(); + delete env.ENCRYPTION_KEY; + env.ENCRYPTION_KEYS = JSON.stringify({ v1: HEX32, v2: HEX32 }); + const report = collectReadiness(env); + expect(report.hasBlocker).toBe(false); + expect( + report.lines.find((l) => l.label === "ENCRYPTION_KEYS")?.status, + ).toBe("ok"); + }); + + it("flags a malformed ENCRYPTION_KEYS map", () => { + const env = baseEnv(); + delete env.ENCRYPTION_KEY; + env.ENCRYPTION_KEYS = '{"v1":"nothex"}'; + expect(collectReadiness(env).hasBlocker).toBe(true); + }); + + it("flags a missing DATABASE_URL", () => { + const env = baseEnv(); + delete env.DATABASE_URL; + expect(collectReadiness(env).hasBlocker).toBe(true); + }); +}); + +describe("collectReadiness — transport + optional", () => { + it("warns Secure-on when NODE_ENV=production and no override", () => { + const line = lineFor(baseEnv(), "SESSION_COOKIE_SECURE"); + expect(line?.detail).toContain("Secure cookie flag ON"); + }); + + it("notes Secure-off when SESSION_COOKIE_SECURE=false", () => { + const env = { ...baseEnv(), SESSION_COOKIE_SECURE: "false" }; + expect(lineFor(env, "SESSION_COOKIE_SECURE")?.detail).toContain( + "Secure cookie flag OFF", + ); + }); + + it("marks optional subsystems as warn when unconfigured", () => { + const smtp = lineFor(baseEnv(), "SMTP (email)"); + expect(smtp?.status).toBe("warn"); + }); + + it("marks off-host backup ok only when every required var is set", () => { + const env = { + ...baseEnv(), + BACKUP_S3_ENDPOINT: "https://r2", + BACKUP_S3_BUCKET: "b", + BACKUP_S3_ACCESS_KEY: "a", + BACKUP_S3_SECRET_KEY: "s", + BACKUP_ENCRYPTION_KEY: HEX32, + }; + expect(lineFor(env, "Off-host backup")?.status).toBe("ok"); + }); +}); + +describe("formatReadiness + logReadinessSummary", () => { + it("renders a header that flags a blocker", () => { + const env = baseEnv(); + delete env.API_TOKEN_HMAC_KEY; + const text = formatReadiness(collectReadiness(env)); + expect(text).toContain("CORE SECRET MISSING/INVALID"); + expect(text).toContain("✗ API_TOKEN_HMAC_KEY"); + }); + + it("logs blockers to error and healthy summaries to log; never throws", () => { + const errors: string[] = []; + const logs: string[] = []; + const sink = { + error: (m: string) => errors.push(m), + log: (m: string) => logs.push(m), + }; + logReadinessSummary(baseEnv(), sink); + expect(logs).toHaveLength(1); + expect(errors).toHaveLength(0); + + const bad = baseEnv(); + delete bad.ENCRYPTION_KEY; + logReadinessSummary(bad, sink); + expect(errors).toHaveLength(1); + }); +}); diff --git a/src/lib/boot/readiness-summary.ts b/src/lib/boot/readiness-summary.ts new file mode 100644 index 000000000..6f1e79f15 --- /dev/null +++ b/src/lib/boot/readiness-summary.ts @@ -0,0 +1,197 @@ +/** + * First-boot readiness summary. + * + * The bundled image promises "single `docker compose up`", but the reality is + * three hand-generated secrets plus a handful of optional subsystems — and the + * crypto loader is fail-closed, so a missing/short `ENCRYPTION_KEY` throws a + * stack trace on first use rather than a sentence an operator can act on. This + * module inspects the environment ONCE at boot and prints a compact, + * human-readable readiness block: a clear line per core secret (with the exact + * `openssl` fix on a bad one) before anything throws, plus a one-glance status + * of the optional channels. + * + * It NEVER logs a secret value — only presence + shape validity. It never + * throws: the fail-closed loaders remain the real gate; this is a friendlier + * signpost in front of them. + */ + +export interface ReadinessLine { + /** Short label, e.g. "ENCRYPTION_KEY". */ + label: string; + /** ok = green, warn = optional-not-configured, error = missing/invalid core. */ + status: "ok" | "warn" | "error"; + /** One-line human detail (no secret material). */ + detail: string; +} + +export interface ReadinessReport { + lines: ReadinessLine[]; + /** True when at least one core secret is missing or malformed. */ + hasBlocker: boolean; +} + +type Env = Record; + +/** A 64-char hex string (= 32 bytes) or a base64 string that decodes to 32 bytes. */ +function isValid32ByteKey(raw: string | undefined): boolean { + if (!raw) return false; + const v = raw.trim(); + if (/^[0-9a-fA-F]{64}$/.test(v)) return true; + if (/^[A-Za-z0-9+/=]+$/.test(v)) { + try { + return Buffer.from(v, "base64").length === 32; + } catch { + return false; + } + } + return false; +} + +function allSet(env: Env, keys: string[]): boolean { + return keys.every((k) => (env[k] ?? "").trim().length > 0); +} + +function anySet(env: Env, keys: string[]): boolean { + return keys.some((k) => (env[k] ?? "").trim().length > 0); +} + +/** Inspect the environment and build the readiness report. Pure + side-effect free. */ +export function collectReadiness(env: Env = process.env): ReadinessReport { + const lines: ReadinessLine[] = []; + + // --- Core secrets (boot blockers) --------------------------------------- + // Encryption: the map form (ENCRYPTION_KEYS) supersedes the single key. + const keysMap = (env.ENCRYPTION_KEYS ?? "").trim(); + let encryptionOk = false; + if (keysMap) { + try { + const parsed = JSON.parse(keysMap) as Record; + const entries = Object.values(parsed ?? {}); + encryptionOk = + entries.length > 0 && entries.every((v) => isValid32ByteKey(v)); + } catch { + encryptionOk = false; + } + lines.push({ + label: "ENCRYPTION_KEYS", + status: encryptionOk ? "ok" : "error", + detail: encryptionOk + ? "key-rotation map present and valid" + : "ENCRYPTION_KEYS is not a JSON map of valid 32-byte keys", + }); + } else { + encryptionOk = isValid32ByteKey(env.ENCRYPTION_KEY); + lines.push({ + label: "ENCRYPTION_KEY", + status: encryptionOk ? "ok" : "error", + detail: encryptionOk + ? "present (64 hex chars)" + : "missing or not 64 hex chars — generate with: openssl rand -hex 32", + }); + } + + const hmacOk = isValid32ByteKey(env.API_TOKEN_HMAC_KEY); + lines.push({ + label: "API_TOKEN_HMAC_KEY", + status: hmacOk ? "ok" : "error", + detail: hmacOk + ? "present (64 hex chars)" + : "missing or not 64 hex chars — generate with: openssl rand -hex 32", + }); + + const dbOk = (env.DATABASE_URL ?? "").trim().length > 0; + lines.push({ + label: "DATABASE_URL", + status: dbOk ? "ok" : "error", + detail: dbOk + ? "present" + : "missing — the bundled compose derives it from POSTGRES_PASSWORD", + }); + + // --- Transport hint (a warning, never a blocker) ------------------------ + const secureOverride = (env.SESSION_COOKIE_SECURE ?? "").trim().toLowerCase(); + const secureFlagOn = + secureOverride === "true" || + (secureOverride !== "false" && env.NODE_ENV === "production"); + lines.push({ + label: "SESSION_COOKIE_SECURE", + status: "ok", + detail: secureFlagOn + ? "Secure cookie flag ON — serve HTTPS, or set SESSION_COOKIE_SECURE=false for a plain-HTTP LAN host" + : "Secure cookie flag OFF — fine for plain-HTTP LAN; do NOT expose plain HTTP to the internet", + }); + + // --- Optional subsystems (informational) -------------------------------- + const optional: Array<[string, boolean]> = [ + ["SMTP (email)", allSet(env, ["SMTP_HOST", "SMTP_PORT", "SMTP_FROM"])], + [ + "Off-host backup", + allSet(env, [ + "BACKUP_S3_ENDPOINT", + "BACKUP_S3_BUCKET", + "BACKUP_S3_ACCESS_KEY", + "BACKUP_S3_SECRET_KEY", + "BACKUP_ENCRYPTION_KEY", + ]), + ], + [ + "APNs (native iOS push)", + allSet(env, ["APNS_KEY_ID", "APNS_TEAM_ID", "APNS_BUNDLE_ID"]) && + anySet(env, ["APNS_KEY_B64", "APNS_KEY", "APNS_KEY_FILE"]), + ], + [ + "Web Push (VAPID env)", + allSet(env, ["VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", "VAPID_SUBJECT"]), + ], + ]; + for (const [label, configured] of optional) { + lines.push({ + label, + status: configured ? "ok" : "warn", + detail: configured ? "configured" : "not configured (optional)", + }); + } + + const hasBlocker = lines.some((l) => l.status === "error"); + return { lines, hasBlocker }; +} + +const GLYPH: Record = { + ok: "✓", + warn: "·", + error: "✗", +}; + +/** Render the report as a compact multi-line block. */ +export function formatReadiness(report: ReadinessReport): string { + const body = report.lines + .map((l) => ` ${GLYPH[l.status]} ${l.label}: ${l.detail}`) + .join("\n"); + const header = report.hasBlocker + ? "HealthLog boot readiness — CORE SECRET MISSING/INVALID (see ✗ below):" + : "HealthLog boot readiness:"; + return `${header}\n${body}`; +} + +/** + * Collect + print the readiness summary. Blockers go to stderr (they precede a + * fail-closed throw), the healthy summary to stdout. Never throws. + */ +export function logReadinessSummary( + env: Env = process.env, + log: Pick = console, +): ReadinessReport { + try { + const report = collectReadiness(env); + const text = formatReadiness(report); + if (report.hasBlocker) { + log.error(text); + } else { + log.log(text); + } + return report; + } catch { + // A readiness summary must never be the thing that breaks boot. + return { lines: [], hasBlocker: false }; + } +} diff --git a/src/lib/intl/__tests__/formatter-cache.test.ts b/src/lib/intl/__tests__/formatter-cache.test.ts new file mode 100644 index 000000000..e55e3b643 --- /dev/null +++ b/src/lib/intl/__tests__/formatter-cache.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; + +import { getNumberFormat, getDateTimeFormat } from "../formatter-cache"; + +describe("formatter-cache", () => { + it("returns the same NumberFormat instance for identical (locale, options)", () => { + const a = getNumberFormat("en", { maximumFractionDigits: 2 }); + const b = getNumberFormat("en", { maximumFractionDigits: 2 }); + expect(a).toBe(b); + }); + + it("returns distinct NumberFormat instances for different options", () => { + const a = getNumberFormat("en", { maximumFractionDigits: 2 }); + const b = getNumberFormat("en", { maximumFractionDigits: 0 }); + expect(a).not.toBe(b); + }); + + it("returns distinct instances per locale", () => { + const a = getNumberFormat("en", { maximumFractionDigits: 1 }); + const b = getNumberFormat("de", { maximumFractionDigits: 1 }); + expect(a).not.toBe(b); + }); + + it("caches DateTimeFormat and still formats correctly", () => { + const a = getDateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const b = getDateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + expect(a).toBe(b); + expect(a.format(new Date("2026-05-08T12:00:00Z"))).toBe("2026-05-08"); + }); +}); diff --git a/src/lib/intl/formatter-cache.ts b/src/lib/intl/formatter-cache.ts new file mode 100644 index 000000000..cc624f458 Binary files /dev/null and b/src/lib/intl/formatter-cache.ts differ diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index b2ffc0398..307617c02 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -6,6 +6,7 @@ import { runOffhostBackup, runOffhostRoundtripTest, } from "../offhost-backup"; +import { MEASUREMENT_BACKUP_PAGE_SIZE } from "../backup/measurement-page"; const ENC_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -138,6 +139,56 @@ describe("runOffhostBackup", () => { expect(parsed.userId).toBe("u1"); }); + it("keyset-pages the intake/mood/cycle tables across multiple pages", async () => { + const s3 = makeS3Mock(); + // A full first page (=== page size) followed by a short page proves the + // reader walks the id cursor instead of one unbounded findMany. + const fullPage = Array.from( + { length: MEASUREMENT_BACKUP_PAGE_SIZE }, + (_, i) => ({ + id: `e${String(i).padStart(5, "0")}`, + userId: "u1", + takenAt: new Date("2026-05-01T00:00:00Z"), + }), + ); + const tailPage = [ + { id: "z9999", userId: "u1", takenAt: new Date("2026-05-02T00:00:00Z") }, + ]; + const intakeFindMany = vi.fn( + async (args: { where?: { id?: { gt?: string } } }) => + args?.where?.id?.gt ? tailPage : fullPage, + ); + + const prisma = { + user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) }, + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: intakeFindMany }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + }; + + const report = await runOffhostBackup( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + s3, + new Date("2026-05-08T00:00:00Z"), + ); + expect(report.uploaded).toBe(1); + // Two round-trips: the full page, then the short tail that ends the walk. + expect(intakeFindMany).toHaveBeenCalledTimes(2); + expect(intakeFindMany.mock.calls[0][0]).toMatchObject({ + orderBy: { id: "asc" }, + take: MEASUREMENT_BACKUP_PAGE_SIZE, + }); + // Every paged row lands in the decrypted payload — no rows dropped. + const ct = s3.store.get("2026-05-08/user-u1.json.enc")!; + const parsed = JSON.parse(decryptBackup(ct, Buffer.from(ENC_KEY, "hex"))); + expect(parsed.intakeEvents).toHaveLength(MEASUREMENT_BACKUP_PAGE_SIZE + 1); + }); + it("counts per-user failures without aborting the whole run", async () => { const s3 = makeS3Mock(); const prisma = { diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 2a6118c1a..d0c2ee672 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -279,16 +279,70 @@ export async function runOffhostBackup( where: { userId: user.id }, include: { schedules: true }, }), - prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }), - prisma.moodEntry.findMany({ where: { userId: user.id } }), + // The intake / mood / cycle tables scale with history depth the same + // way measurements do (multiple intakes + a mood + a cycle-day-log per + // day over years), so they get the same keyset-paged read: at most one + // page is resident per round-trip instead of the entire table landing + // in one array alongside the giant JSON.stringify below. The projection + // keeps the full row (no select), so payload bytes are identical to the + // prior default findMany — only peak heap during the read is bounded. + collectPagedMeasurements({ + fetchPage: (afterId, take) => + prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + ...(afterId ? { id: { gt: afterId } } : {}), + }, + orderBy: { id: "asc" }, + take, + }), + project: (row) => row, + pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, + }), + collectPagedMeasurements({ + fetchPage: (afterId, take) => + prisma.moodEntry.findMany({ + where: { + userId: user.id, + ...(afterId ? { id: { gt: afterId } } : {}), + }, + orderBy: { id: "asc" }, + take, + }), + project: (row) => row, + pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, + }), // v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a // disaster-recovery dump, not the canonical user-facing backup); - // `notesEncrypted` stays ciphertext. + // `notesEncrypted` stays ciphertext. The profile is a single row, so it + // stays a direct read; the cycle + day-log tables are paged. prisma.cycleProfile.findUnique({ where: { userId: user.id } }), - prisma.menstrualCycle.findMany({ where: { userId: user.id } }), - prisma.cycleDayLog.findMany({ - where: { userId: user.id }, - include: { symptomLinks: true }, + collectPagedMeasurements({ + fetchPage: (afterId, take) => + prisma.menstrualCycle.findMany({ + where: { + userId: user.id, + ...(afterId ? { id: { gt: afterId } } : {}), + }, + orderBy: { id: "asc" }, + take, + }), + project: (row) => row, + pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, + }), + collectPagedMeasurements({ + fetchPage: (afterId, take) => + prisma.cycleDayLog.findMany({ + where: { + userId: user.id, + ...(afterId ? { id: { gt: afterId } } : {}), + }, + include: { symptomLinks: true }, + orderBy: { id: "asc" }, + take, + }), + project: (row) => row, + pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, }), ]);