Merge branch 'fix/coldstart-perf' into release/v1.27.32

This commit is contained in:
Marc-André Bombeck committed 2026-07-09 02:08:46 +02:00
commit 57c7972755
27 files changed
+1040 -179

No files matched your search

+17 -8
View File
@@ -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"
# -----------------------------------------------------------------------------
+24 -5
View File
@@ -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
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+104 -23
View File
@@ -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<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
};
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<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
};
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);
});
});
+147 -64
View File
@@ -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<number, "inserted" | "updated" | "duplicate">();
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<string>();
const extInserts = new Map<string, NormalisedMeasurementRow>();
const extUpdates = new Map<string, NormalisedMeasurementRow>();
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<string>();
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.
+9 -9
View File
@@ -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
* `<div>` 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 (
<OnboardingShell step={2} userLocale={user.locale ?? null}>
@@ -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) && (
<p
data-slot="registration-open-hint"
className="text-muted-foreground -mt-2 text-xs"
>
{t("admin.registrationOpenHint")}
</p>
)}
{/* 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 /
@@ -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 — `<RecentWorkoutsTile>`.
@@ -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",
+5 -4
View File
@@ -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),
+4 -3
View File
@@ -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",
@@ -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",
+78 -45
View File
@@ -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<Theme>(() => {
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 <html> 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 (
+12
View File
@@ -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.
+61 -1
View File
@@ -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<string, string>, 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();
});
});
+16 -2
View File
@@ -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,
+54
View File
@@ -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`."
);
}
@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest";
import {
collectReadiness,
formatReadiness,
logReadinessSummary,
} from "../readiness-summary";
const HEX32 =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
function baseEnv(): Record<string, string | undefined> {
return {
ENCRYPTION_KEY: HEX32,
API_TOKEN_HMAC_KEY: HEX32,
DATABASE_URL: "postgresql://healthlog:pw@db:5432/healthlog",
NODE_ENV: "production",
};
}
function lineFor(env: Record<string, string | undefined>, 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);
});
});
+197
View File
@@ -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<string, string | undefined>;
/** 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<string, string>;
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<ReadinessLine["status"], string> = {
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, "log" | "error"> = 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 };
}
}
@@ -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");
});
});
Binary file not shown.
@@ -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 = {
+61 -7
View File
@@ -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,
}),
]);