From bea6b9fed48db3adec54b54ebb52de1fe76c789e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 23:33:19 +0200 Subject: [PATCH 1/3] feat(demo): allow chart display-pref toggles under demo mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DEMO_MODE block 403s every non-GET /api/* request outside a narrow allowlist, which also caught the above-chart display toggles — the chart-overlay switches and the comparison-baseline selector — even though they only write the caller's own dashboard-prefs blob. Admit PUT /api/dashboard/chart-overlay-prefs and PUT /api/dashboard/widgets in demo mode. The allowlist now pins method per path, so the widgets PUT does not drag in the layout-reset DELETE. The block only runs under DEMO_MODE=true, so production is unaffected. --- .../proxy-demo-mode-allowlist.test.ts | 75 +++++++++++++++++++ src/proxy.ts | 31 ++++++-- 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/proxy-demo-mode-allowlist.test.ts diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts new file mode 100644 index 000000000..6e0043b97 --- /dev/null +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Demo-mode mutation allowlist. On a `DEMO_MODE=true` deploy the proxy + * 403s every non-GET `/api/*` request except a narrow allowlist. Login + * is the historical baseline; the two dashboard display-pref writes + * (chart-overlay toggles + comparison-baseline selector) were added so + * the above-chart toggles work in the demo. They touch nothing but the + * caller's own `User.dashboardWidgetsJson` blob. + * + * This guard locks the allowlist shape in place so a refactor can't + * silently widen it (admitting a data-bearing mutation) or narrow it + * (re-breaking the demo toggles). The whole block is gated on + * `DEMO_MODE === "true"`, so production is unaffected by construction — + * the final case proves the gate. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +function makeRequest(pathname: string, method: string): NextRequest { + return new NextRequest(`http://localhost${pathname}`, { method }); +} + +const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE; + +describe("proxy.ts DEMO_MODE mutation allowlist", () => { + beforeEach(() => { + process.env.DEMO_MODE = "true"; + }); + + afterEach(() => { + if (ORIGINAL_DEMO_MODE === undefined) { + delete process.env.DEMO_MODE; + } else { + process.env.DEMO_MODE = ORIGINAL_DEMO_MODE; + } + }); + + it("permits the chart-overlay-prefs PUT (above-chart display toggles)", () => { + const res = proxy( + makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"), + ); + expect(res.status).not.toBe(403); + }); + + it("permits the dashboard-widgets PUT (comparison-baseline selector)", () => { + const res = proxy(makeRequest("/api/dashboard/widgets", "PUT")); + expect(res.status).not.toBe(403); + }); + + it("still blocks a health-data mutation (POST /api/measurements)", () => { + const res = proxy(makeRequest("/api/measurements", "POST")); + expect(res.status).toBe(403); + }); + + it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => { + // The allowlist pins method per path; admitting the widgets PUT + // must not open DELETE (which wipes the user's dashboard layout). + const res = proxy(makeRequest("/api/dashboard/widgets", "DELETE")); + expect(res.status).toBe(403); + }); + + it("is inert when DEMO_MODE is off — production is unaffected", () => { + delete process.env.DEMO_MODE; + // With the demo gate off, the proxy never short-circuits an API + // mutation; the route's own auth/handler runs instead. + const res = proxy(makeRequest("/api/measurements", "POST")); + expect(res.status).not.toBe(403); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index 98daa8a79..382b7c815 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -83,12 +83,29 @@ function isPublicPath(pathname: string): boolean { /** * API paths that are allowed to accept mutations (POST/PUT/PATCH/DELETE) * even when DEMO_MODE is enabled. Everything else is read-only. + * + * Each entry pins the exact path AND the exact method, so admitting one + * verb on a route never opens its siblings (e.g. the dashboard-widgets + * PUT must not drag in the layout-reset DELETE on the same path). + * + * The login family is the historical baseline — a demo visitor still has + * to authenticate. The two dashboard entries are display-only preference + * writes: idempotent, user-scoped, Zod-validated, and touching nothing but + * the caller's own `User.dashboardWidgetsJson` blob (chart-overlay toggles + * + comparison-baseline selector). They carry no health data and are safe + * to exercise in the demo so the above-chart toggles work there. This whole + * block only runs under `DEMO_MODE=true`, so production (apps01) is + * unaffected by construction. */ -const DEMO_MUTATION_ALLOWLIST = [ - "/api/auth/login", - "/api/auth/passkey/login-options", - "/api/auth/passkey/login-verify", -]; +const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> = + [ + { path: "/api/auth/login", method: "POST" }, + { path: "/api/auth/passkey/login-options", method: "POST" }, + { path: "/api/auth/passkey/login-verify", method: "POST" }, + // Chart display-pref toggles above the charts (dashboard + insights): + { path: "/api/dashboard/chart-overlay-prefs", method: "PUT" }, + { path: "/api/dashboard/widgets", method: "PUT" }, + ]; // Legacy route redirects (German → English) const LEGACY_REDIRECTS: Record = { @@ -170,7 +187,9 @@ export function proxy(request: NextRequest) { if ( isApi && isMutation && - !DEMO_MUTATION_ALLOWLIST.some((p) => pathname === p) + !DEMO_MUTATION_ALLOWLIST.some( + (entry) => pathname === entry.path && method === entry.method, + ) ) { return NextResponse.json( { From 42fcc22e39aa8bd884075c27f966d79f72e6deaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 23:36:37 +0200 Subject: [PATCH 2/3] fix(import): raise the middleware body cap so large Apple Health exports import Next 16 truncated any request body over ~10MB passing through middleware (src/proxy.ts) before the route handler saw it, so a multi-MB export.zip lost its ZIP end-of-central-directory and failed to parse (GitHub #281). Set middlewareClientMaxBodySize so real exports arrive intact. --- next.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/next.config.ts b/next.config.ts index 7440d153a..4cef1bf1c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -157,8 +157,18 @@ const nextConfig: NextConfig = { outputFileTracingExcludes: { "*": ["./next.config.ts"], }, + // Next 16 caps the request body that passes THROUGH middleware (our + // `src/proxy.ts`) at ~10MB by default, silently truncating larger bodies + // before the route handler sees them. The Apple Health `export.zip` importer + // (`/api/import/apple-health-export`) streams multi-MB archives to disk; the + // 10MB cap truncated them so the ZIP end-of-central-directory was lost and + // the parser failed (GitHub #281). Raise the ceiling so real exports pass + // intact. The value is a ceiling, not a buffer — only the actual upload size + // is held — so this is safe; very large exports also want a matching + // reverse-proxy body limit (see docs/self-hosting/reverse-proxy.md). experimental: { optimizePackageImports: ["recharts", "lucide-react"], + middlewareClientMaxBodySize: "512mb", }, }; From 8efdac7c5a99b4e8a5286eb302faa605fe43260f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 23:38:01 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.15.17=20=E2=80=94=20la?= =?UTF-8?q?rge=20imports,=20demo=20chart=20toggles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++ docs/api/openapi.yaml | 2 +- package.json | 2 +- scripts/migrate-demo-edge01.README.md | 156 ++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 scripts/migrate-demo-edge01.README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 125dd8214..5b3480868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [1.15.17] — 2026-06-07 — large imports, demo chart toggles + +### Fixed + +- **Large Apple Health exports import correctly.** A request-body size limit in the middleware layer truncated uploads over ~10 MB before they reached the importer, so a multi-megabyte `export.zip` lost its ZIP structure and failed with an "end-of-central-directory" error. The limit is raised so real exports arrive intact. (GitHub #281) + +### Changed + +- **Chart display toggles work in demo mode.** On a demo instance, the comparison-baseline selector and per-chart overlay toggles above charts are no longer blocked — these are harmless display preferences. + ## [1.15.16] — 2026-06-07 — wellness ring glow no longer clipped ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9ebf336d2..926ccfe89 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.15.16 + version: 1.15.17 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index a231fcced..54801ba95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.16", + "version": "1.15.17", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/scripts/migrate-demo-edge01.README.md b/scripts/migrate-demo-edge01.README.md new file mode 100644 index 000000000..258e64cf6 --- /dev/null +++ b/scripts/migrate-demo-edge01.README.md @@ -0,0 +1,156 @@ +# migrate-demo-edge01.sh + +Copies **exactly one** user — the demo account +`usr_demo_cf31025295714ece8d91f5af13afd76d` — from the production apps01 +Postgres to the **separate** edge01 demo Postgres. No other user's rows are +transferred under any circumstance. + +> Review the script first, then run it deliberately. It performs destructive, +> demo-scoped deletes on the edge01 demo DB, then loads the apps01 demo slice. +> It never writes to apps01 (source is read-only). + +## How it connects + +- **Source (apps01):** SSH alias `apps-01`, container `db-pg8wggwogo8c4gc4ks0kk4ss` + (stable), psql `healthlog`/`healthlog`. +- **Target (edge01):** SSH alias `edge-01`. The demo Postgres is a separate + Coolify resource whose container name is **not hardcoded** — the script + resolves it at runtime via `docker ps | grep -iE 'postgres|^db-'` and aborts + if it cannot pick a single match (override with `DST_DB_CONTAINER=db-xxxx`). + The demo **app** uuid is `ck8cs4osswg8w440gskw08w8`; match its linked + Postgres resource, not the docs/landing DB. +- **Edge demo user id is resolved at runtime** by `username='demo'` (it may + differ from the apps01 id). The wipe phase is scoped to that resolved id; the + load then brings the apps01 row in, so afterwards the edge01 demo user + carries the apps01 canonical id. + +Each table is transferred with a scoped, server-side `\copy ... TO STDOUT` +piped host-to-host into `\copy ... FROM STDIN`, using **explicit column lists +on both ends** so secret columns can be NULLed and column order is pinned. + +## What moves (FK-parent-first, 20 tables) + +| # | Table | Scope predicate | +|---|---|---| +| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) | +| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) | +| 3 | `cycle_profiles` | `user_id = demo` | +| 4 | `menstrual_cycles` | `user_id = demo` | +| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) | +| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` | +| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` | +| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only | +| 9 | `medications` | `user_id = demo` | +| 10 | `medication_schedules` | `medication_id IN (demo's meds)` | +| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` | +| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` | +| 13 | `medication_inventory_items` | `user_id = demo` | +| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` | +| 15 | `medication_side_effects` | `user_id = demo` | +| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` | +| 17 | `personal_records` | `user_id = demo` | +| 18 | `user_achievements` | `user_id = demo` | +| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) | +| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) | + +## Secret / credential columns NULLed on the `users` row + +`codex_access_token_encrypted`, `codex_refresh_token_encrypted`, +`codex_token_expires_at`, `codex_connected_at` (and `codex_connection_status` +reset to `'disconnected'`), `ai_anthropic_key_encrypted`, +`ai_local_key_encrypted`, `ai_openai_key_encrypted`, +`withings_client_id_encrypted`, `withings_client_secret_encrypted`, +`whoop_client_id_encrypted`, `whoop_client_secret_encrypted`, +`fitbit_client_id_encrypted`, `fitbit_client_secret_encrypted`, +`telegram_bot_token`, `telegram_chat_id`, `mood_log_url_encrypted`, +`mood_log_api_key_encrypted`, `mood_log_webhook_secret`, +`insurance_number_encrypted`, `insurer_name`, `insurer_ik_number`. + +Forced flags: `telegram_enabled=false`, `mood_log_enabled=false`, +`role='ADMIN'`. + +**Kept:** `password_hash` (demo login works), `avatar_bytes` / +`avatar_content_type` / `avatar_updated_at` (profile photo), and all +display / preference / layout JSON (`thresholds_json`, +`dashboard_widgets_json`, `insights_layout_json`, `coach_prefs_json`, +`source_priority_json`, `doctor_report_prefs_json`, `notification_prefs`, +`healthkit_config_json`, unit prefs, `height_cm`, `gender`, `timezone`, +`locale`, `date_of_birth`, `display_name`, onboarding state, research-mode +acknowledgement). + +## What is excluded — and why + +- **Auth / device / push** (sessions, passkeys, auth_challenges, api_tokens, + refresh_tokens, devices, push_subscriptions, push_attempts, + notification_channels, notification_preferences) — per-environment, never + portable. +- **Integration / OAuth** (withings/whoop/fitbit connections, all + `*_oauth_states` / `*_connect_tickets`, telegram scheduled-deletions + + reminder messages, clinician_share_links, provider_health) — tied to live + credentials. +- **Encrypted-at-rest payloads we can't decrypt on edge01:** + `insight_narratives` and all `coach_*` (coach_conversations, coach_messages, + coach_facts, coach_usage). The edge01 instance does not hold the prod + encryption key, so the AES-256-GCM ciphertext would be unreadable. The same + reason drives NULLing `cycle_day_logs.sensitive_encrypted` / + `notes_encrypted` while still carrying that table's plaintext cycle fields. + The plaintext AI assessment text is preserved through the `insights.%` + `audit_logs` rows (the text lives in the `details` JSON) instead. +- **Transient / regenerable:** idempotency_keys, rate_limits, data_backups, + import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup + tables (measurement_rollups, mood_entry_rollups, + medication_compliance_rollups, strain_trimp_cache, cycle_predictions) — these + rebuild on edge01. +- **No rows:** workouts / workout_routes / workout_samples (demo has none). +- **Instance / global:** app_settings; global catalogues (mood_tags, + cycle_symptoms) — these pre-exist on edge01 and the carried link rows + reference the global ids only. + +## Encryption note + +The edge01 demo instance is assumed **not** to share the apps01 encryption +key. Every column whose value is AES-256-GCM ciphertext under the prod key is +either NULLed (secrets on `users`, `cycle_day_logs.*_encrypted`) or skipped +entirely (`insight_narratives`, `coach_messages.encryptedContent`). Carrying +that ciphertext would produce undecryptable rows that the app's fail-closed +loader would reject. + +## role = ADMIN + DEMO_MODE expectations + +The demo user is loaded with `role='ADMIN'` so the admin surfaces are +viewable in the demo. This is **only safe because DEMO_MODE must stay ON** on +edge01 — the proxy layer (`src/proxy.ts`) blocks every mutating request in +demo mode, so an admin demo session can browse admin pages but cannot change +anything. If DEMO_MODE were ever off on edge01, this admin demo account would +be a write-capable admin. Keep DEMO_MODE enabled. + +## Safety mechanisms built into the script + +- `set -euo pipefail` and `ON_ERROR_STOP=1` on every psql call. +- **Pre-flight guard (apps01):** for each carried set, a query that must + return `0` foreign-owned rows — aborts on any non-zero. Includes a check + that the demo owns **0 custom mood_tags** (so every carried + `mood_entry_tag_link` points at a global tag that pre-exists on edge01) plus + a defensive "non-global tag" link count. +- **Scoped wipe-first (edge01):** FK-child-first deletes, every one scoped to + the runtime-resolved edge01 demo id. No unscoped delete anywhere. Refuses to + proceed if `username='demo'` on edge01 resolves to the operator id. +- **Post-load verification (edge01):** per-table demo row counts; a hard + assertion that the operator id `cmlupy4tn000001rpzx1pxvz7` appears in zero + rows; a check that user_id-bearing carried tables hold only the demo id; and + a confirmation the demo user exists with `role=ADMIN`. + +## Caveats / things to confirm before running + +- **`FOREIGN_HITS` assertion** counts *all* non-demo rows in the carried + tables on edge01, not just what this run inserted. On a clean + single-demo-user DB it is `0`. If edge01 legitimately hosts other demo-only + accounts, treat a non-zero value as a warning to inspect, not an automatic + failure (the script prints it as a WARNING, not an abort). +- Confirm edge01 psql credentials are `healthlog`/`healthlog` (the script + verifies connectivity before any write; adjust `PG_USER` / `PG_DB` if the + demo Postgres resource uses different ones). +- The script assumes edge01's schema is at the same (or a compatible) Prisma + migration level as apps01. A column present on apps01 but missing on edge01 + fails the `\copy` loudly rather than silently corrupting data — run + migrations on edge01 first if the two have drifted.