mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The edge01 demo refresh copied `password_hash` straight from the source account, and the README beside it asserted "demo login works". That only held while the source account happened to use the password the project publishes. The source is the operator's own instance, so the moment that password was rotated the demo started rejecting the credentials the README hands to every visitor, and nothing anywhere noticed. Someone eventually filed it as a bug (#805) after being locked out of the shop window. The load now hands the row an explicit password: the same Argon2id literal the seed script writes, applied right after the copy, then read back and compared byte for byte. A mismatch aborts the run rather than waiting for a stranger to discover it at a login screen. The statement goes in on psql's stdin. Interpolating it into the ssh command string would let the remote shell expand the hash's `$argon2id`, `$v`, `$m`, `$t` and `$p` segments to empty strings and store something no password can ever match, which is the same outage wearing a different hat. A guard test pins that too, alongside pinning the literal to the seed script's copy so the two cannot drift apart again.
166 lines
10 KiB
Markdown
166 lines
10 KiB
Markdown
# 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`,
|
|
`insurance_number_encrypted`, `insurer_name`, `insurer_ik_number`.
|
|
|
|
Forced flags: `telegram_enabled=false`,
|
|
`role='ADMIN'`.
|
|
|
|
**Overwritten:** `password_hash`. The row arrives carrying the source
|
|
account's hash, and the load is immediately followed by an `UPDATE` that sets
|
|
the Argon2id hash of the demo password the README publishes, then asserts the
|
|
stored value matches it byte for byte and aborts if it does not. The source is
|
|
the operator's own instance, so its demo password can be rotated at any time;
|
|
carrying that hash across is what silently broke the advertised demo login in
|
|
issue #805. The statement travels on psql's stdin, never inside the ssh command
|
|
string, because the hash contains `$argon2id` / `$v` / `$m` / `$t` / `$p`
|
|
segments that a remote shell would expand to nothing.
|
|
|
|
**Kept:** `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.
|