diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3bf2d402..157eb8949 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -185,7 +185,7 @@ If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`): ### Long-term -Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`. +Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback. ## Code of Conduct diff --git a/scripts/README.md b/scripts/README.md index 34949a93e..4f7b49c39 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -37,7 +37,7 @@ typecheck. - **`repair-google-health-sleep.ts`**: re-syncs every Google Health connection so nights stored twice before v1.28.18 collapse to one set; dry-run by default, `--run` to execute. - **`i18n-key-tool.mjs`**, **`split-description.mjs`**: edit one key across every `messages/*.json` (show, set, drop) and move an over-long card description's tail into its own key; **`measure-description-lengths.mjs`** measures the Settings and Admin description strings against the 120-character guideline. - **`audit-column-readers.ts`**: lists write-only schema columns and open `@pending-consumer` markers; a review tool, not a gate. -- **`migrate-demo-edge01.sh`**: copies the demo account from one instance to a separate demo instance; see its README. +- **`migrate-demo-account.sh`**: copies the demo account from one instance to a separate demo instance; see its README. - **`__tests__/`**: tests for the scripts above. ## Conventions diff --git a/scripts/__tests__/demo-published-password-guard.test.ts b/scripts/__tests__/demo-published-password-guard.test.ts index e9167a684..eac5f20c4 100644 --- a/scripts/__tests__/demo-published-password-guard.test.ts +++ b/scripts/__tests__/demo-published-password-guard.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest"; * demo.healthlog.dev. Its Argon2id hash lives in two places that must agree: * * - scripts/seed-demo.ts writes it when a demo tenant is seeded - * - scripts/migrate-demo-edge01.sh overwrites it after a migration load + * - scripts/migrate-demo-account.sh overwrites it after a migration load * * Public issue #805: the migration used to carry the SOURCE account's hash * across instead, so once the operator rotated their own demo password the @@ -18,7 +18,7 @@ import { describe, expect, it } from "vitest"; const REPO_ROOT = join(__dirname, "..", ".."); const SEED = join(REPO_ROOT, "scripts", "seed-demo.ts"); -const MIGRATE = join(REPO_ROOT, "scripts", "migrate-demo-edge01.sh"); +const MIGRATE = join(REPO_ROOT, "scripts", "migrate-demo-account.sh"); /** Every Argon2id hash literal in a file, in source order. */ function argon2Hashes(path: string): string[] { @@ -27,7 +27,7 @@ function argon2Hashes(path: string): string[] { } describe("published demo password", () => { - it("is the same hash in the seed script and the edge01 migration", () => { + it("is the same hash in the seed script and the demo migration", () => { const seeded = argon2Hashes(SEED); const migrated = argon2Hashes(MIGRATE); @@ -55,7 +55,7 @@ describe("published demo password", () => { // ...and it must be proven, not merely attempted. Without the check a // mangled hash would reach a visitor's login screen instead of the script. expect(script).toContain( - "ABORT/FAIL: demo password_hash on edge01 is not the published literal", + "ABORT/FAIL: demo password_hash on the target is not the published literal", ); }); diff --git a/scripts/migrate-demo-edge01.README.md b/scripts/migrate-demo-account.README.md similarity index 72% rename from scripts/migrate-demo-edge01.README.md rename to scripts/migrate-demo-account.README.md index f21a6414d..dfb6a17dd 100644 --- a/scripts/migrate-demo-edge01.README.md +++ b/scripts/migrate-demo-account.README.md @@ -1,28 +1,30 @@ -# migrate-demo-edge01.sh +# migrate-demo-account.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. +Copies **exactly one** user, the demo account named by `DEMO_USER_ID`, from a +source instance's Postgres to the **separate** Postgres of a demo instance. 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). +> demo-scoped deletes on the target demo DB, then loads the demo slice from the +> source. It never writes to the source (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. +Everything instance-specific comes from the environment: + +- `DEMO_USER_ID`: the user id to copy. `PROTECT_USER_ID`: an id that must + never appear on the target (the operator's own account). +- `SRC_SSH` and `SRC_DB_CONTAINER_PATTERN`: the source host's ssh alias and a + `grep -E` pattern for its database container; psql `healthlog`/`healthlog`. +- `DST_SSH`: the target host's ssh alias. The demo Postgres container 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`). Match the demo app's own + Postgres, not a docs or landing-page database on the same host. +- **The target demo user id is resolved at runtime** by `username='demo'` (it + may differ from the source id). The wipe phase is scoped to that resolved id; + the load then brings the source row in, so afterwards the target demo user + carries the source's 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 @@ -97,9 +99,9 @@ acknowledgement). `*_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:** +- **Encrypted-at-rest payloads the target cannot decrypt:** `insight_narratives` and all `coach_*` (coach_conversations, coach_messages, - coach_facts, coach_usage). The edge01 instance does not hold the prod + coach_facts, coach_usage). The target does not hold the source's 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. @@ -109,16 +111,16 @@ acknowledgement). import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup tables (measurement_rollups, mood_entry_rollups, strain_trimp_cache, cycle_predictions) — these - rebuild on edge01. + rebuild on the target. - **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 + cycle_symptoms) pre-exist on the target 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 +The demo instance is assumed **not** to share the source's encryption key. +Every column whose value is AES-256-GCM ciphertext under the source 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 @@ -128,38 +130,37 @@ loader would reject. 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 +the target: 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 +anything. If DEMO_MODE were ever off on the target, 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 +- **Pre-flight guard (source):** 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 + `mood_entry_tag_link` points at a global tag that pre-exists on the target) 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 +- **Scoped wipe-first (target):** FK-child-first deletes, every one scoped to + the runtime-resolved target demo id. No unscoped delete anywhere. Refuses to + proceed if `username='demo'` on the target resolves to the operator id. +- **Post-load verification (target):** per-table demo row counts; a hard + assertion that `PROTECT_USER_ID` 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 + tables on the target, not just what this run inserted. On a clean + single-demo-user DB it is `0`. If the target 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 +- Confirm the target's 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. +- The script assumes the target's schema is at the same (or a compatible) + Prisma migration level as the source. A column present on the source but + missing on the target fails the `\copy` loudly rather than silently + corrupting data; run migrations on the target first if the two have drifted. diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-account.sh similarity index 88% rename from scripts/migrate-demo-edge01.sh rename to scripts/migrate-demo-account.sh index d623229f0..df958fc05 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-account.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash # -# migrate-demo-edge01.sh — copy EXACTLY ONE user (the demo account) from the -# production apps01 Postgres to the SEPARATE edge01 demo Postgres. +# migrate-demo-account.sh: copy EXACTLY ONE user (the demo account) from the +# source Postgres to the SEPARATE target demo Postgres. # # ============================================================================ # WHAT THIS SCRIPT DOES (and the isolation guarantees it makes) # ============================================================================ # # It transfers a single, demo-scoped slice of health data so the public demo -# instance on edge01 shows a realistic-looking account WITHOUT carrying any +# instance on the target shows a realistic-looking account WITHOUT carrying any # real-operator data. The ONLY user copied is: # # $DEMO_USER_ID (the single demo account, supplied via env) @@ -26,7 +26,7 @@ # 3. No secret / credential column is transferred. The users SELECT NULLs # every encrypted / token / integration-secret column explicitly. The # cycle-day-log SELECT NULLs the two app-encrypted columns (they are -# keyed to the prod encryption key, which is NOT present on edge01). +# keyed to the prod encryption key, which is NOT present on the target). # # The transfer mechanism per table is a scoped server-side COPY piped # host-to-host: @@ -42,12 +42,12 @@ # - an added/dropped column on either side fails loudly rather than # silently shifting data into the wrong column. # -# Tables load in FK-parent-first order. The edge01 demo rows are wiped first -# (FK-child-first), scoped to the edge01 demo user id resolved at runtime. +# Tables load in FK-parent-first order. The target demo rows are wiped first +# (FK-child-first), scoped to the target demo user id resolved at runtime. # # THIS SCRIPT IS FOR REVIEW. Read it, then run it deliberately. It performs -# DESTRUCTIVE deletes on the edge01 demo DB (scoped to the demo user) followed -# by inserts. It NEVER writes to apps01 (source is read-only). +# DESTRUCTIVE deletes on the target demo DB (scoped to the demo user) followed +# by inserts. It NEVER writes to the source (source is read-only). # # ---------------------------------------------------------------------------- # EXCLUDED ENTIRELY (intentionally NOT copied) @@ -59,16 +59,16 @@ # fitbit_connections, all *_oauth_states / *_connect_tickets, # telegram_scheduled_deletions, telegram_reminder_messages, # clinician_share_links, provider_health. -# - Encrypted-at-rest payloads we cannot decrypt on edge01: +# - Encrypted-at-rest payloads we cannot decrypt on the target: # insight_narratives, coach_messages (coach_* entirely). # - Coach: coach_conversations, coach_messages, coach_facts, coach_usage. # - Ops / transient / regenerable: idempotency_keys, rate_limits, # data_backups, import_jobs, host_metrics, feedback, # recommendation_feedback, all rollup tables (measurement_rollups, -# mood_entry_rollups, strain_trimp_cache, cycle_predictions) — these REGENERATE on edge01. +# mood_entry_rollups, strain_trimp_cache, cycle_predictions) — these REGENERATE on the target. # - workouts / workout_routes / workout_samples — demo has none. # - app_settings — instance-level, not user data. -# - Global catalogues (mood_tags, cycle_symptoms) — pre-exist on edge01; +# - Global catalogues (mood_tags, cycle_symptoms) — pre-exist on the target; # carried link rows reference these GLOBAL ids only (see guards below). # set -euo pipefail @@ -129,21 +129,21 @@ psql_src() { # so psql_dst is defined after resolution. echo "==============================================================" -echo " HealthLog demo-user migration apps01 -> edge01" +echo " HealthLog demo-user migration source -> target" echo " demo user : $DEMO_USER_ID" echo " src host : $SRC_SSH (container $SRC_DB_CONTAINER)" echo " dst host : $DST_SSH (container resolved below)" echo "==============================================================" # ============================================================================ -# RESOLVE the edge01 demo DB container (NOT hardcoded — it is a separate +# RESOLVE the target demo DB container (NOT hardcoded — it is a separate # Coolify Postgres resource whose container name can change on redeploy). # ============================================================================ echo if [[ -n "${DST_DB_CONTAINER:-}" ]]; then echo "## Using DST_DB_CONTAINER from env: $DST_DB_CONTAINER" else - echo "## Resolving edge01 demo Postgres container ..." + echo "## Resolving target demo Postgres container ..." DST_DB_CONTAINER="$(ssh "$DST_SSH" "docker ps --format '{{.Names}}' | grep -iE 'postgres|^db-' || true")" echo "candidate containers on $DST_SSH:" echo "$DST_DB_CONTAINER" | sed 's/^/ /' @@ -159,7 +159,7 @@ else DST_DB_CONTAINER="$(printf '%s\n' "$DST_DB_CONTAINER" | head -n1)" fi readonly DST_DB_CONTAINER -echo "resolved edge01 demo DB container: $DST_DB_CONTAINER" +echo "resolved target demo DB container: $DST_DB_CONTAINER" # Confirm the psql user/db actually work on the resolved container before we # touch anything (fail fast on a wrong container guess). @@ -167,31 +167,31 @@ psql_dst() { # Usage: psql_dst "" ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB $*" } -echo "## Verifying edge01 DB connectivity ..." +echo "## Verifying target DB connectivity ..." psql_dst "-c 'SELECT current_database(), current_user;'" # ============================================================================ -# RESOLVE the edge01 demo user id (it MAY differ from the apps01 id!). -# We resolve by username='demo'. After this migration the edge01 demo user -# will carry the apps01 canonical id (we delete the old row + load the apps01 -# row). Scope EVERY wipe to whatever the edge01 id is right now. +# RESOLVE the target demo user id (it MAY differ from the source id!). +# We resolve by username='demo'. After this migration the target demo user +# will carry the source canonical id (we delete the old row + load the source +# row). Scope EVERY wipe to whatever the target id is right now. # ============================================================================ echo -echo "## Resolving edge01 demo user id (by username='demo') ..." -EDGE_DEMO_ID="$(psql_dst "-tA -c \"SELECT id FROM users WHERE username = 'demo';\"" | tr -d '[:space:]')" -if [[ -z "$EDGE_DEMO_ID" ]]; then - echo "note: no existing demo user on edge01 (username='demo') — wipe phase is a no-op." +echo "## Resolving target demo user id (by username='demo') ..." +TARGET_DEMO_ID="$(psql_dst "-tA -c \"SELECT id FROM users WHERE username = 'demo';\"" | tr -d '[:space:]')" +if [[ -z "$TARGET_DEMO_ID" ]]; then + echo "note: no existing demo user on the target (username='demo') — wipe phase is a no-op." else - echo "edge01 existing demo user id: $EDGE_DEMO_ID" - if [[ "$EDGE_DEMO_ID" == "$REAL_USER_ID" ]]; then - echo "ABORT: edge01 username='demo' resolves to the REAL operator id. Refusing." + echo "existing target demo user id: $TARGET_DEMO_ID" + if [[ "$TARGET_DEMO_ID" == "$REAL_USER_ID" ]]; then + echo "ABORT: target username='demo' resolves to the REAL operator id. Refusing." exit 1 fi fi -readonly EDGE_DEMO_ID +readonly TARGET_DEMO_ID # ============================================================================ -# PRE-FLIGHT GUARD (on apps01 source): assert every carried set is demo-only. +# PRE-FLIGHT GUARD (on the source): assert every carried set is demo-only. # Each query MUST return 0. A non-zero result means a scope predicate is wrong # and we would leak another user's rows — ABORT immediately. # ============================================================================ @@ -243,13 +243,13 @@ preflight "mood_entry_tag_links(join)" \ # GUARD: mood_entry_tag_links must reference ONLY global mood_tags (user_id IS # NULL on the tag). The demo is asserted to have 0 custom mood_tags; if a link -# referenced a per-user tag id, the FK would break on edge01 (that tag row is +# referenced a per-user tag id, the FK would break on the target (that tag row is # not carried). Custom-tag count MUST be 0. DEMO_CUSTOM_MOODTAGS="$(psql_src "-tA -c \"SELECT count(*) FROM mood_tags WHERE user_id = '$DEMO_USER_ID';\"" | tr -d '[:space:]')" echo " demo custom mood_tags = $DEMO_CUSTOM_MOODTAGS (must be 0 — all links point to GLOBAL tags)" if [[ "$DEMO_CUSTOM_MOODTAGS" != "0" ]]; then echo "ABORT: demo user owns $DEMO_CUSTOM_MOODTAGS custom mood_tags. mood_entry_tag_links" - echo " would reference non-global tag ids that are NOT carried -> FK break on edge01." + echo " would reference non-global tag ids that are NOT carried -> FK break on the target." echo " Carry mood_tags (user-scoped) before mood_entry_tag_links, or skip the links." exit 1 fi @@ -260,55 +260,55 @@ preflight "mood_entry_tag_links(non-global tag)" \ echo "## PRE-FLIGHT guard passed — every carried set is demo-only." # ============================================================================ -# WIPE-FIRST on edge01 (idempotent, demo-scoped ONLY). FK-child-first order. -# Every DELETE is scoped to the resolved edge01 demo user id ($EDGE_DEMO_ID) +# WIPE-FIRST on the target (idempotent, demo-scoped ONLY). FK-child-first order. +# Every DELETE is scoped to the resolved target demo user id ($TARGET_DEMO_ID) # or via a join back to it. NEVER an unscoped DELETE. Skipped entirely if no # existing demo user was found. # ============================================================================ echo -if [[ -n "$EDGE_DEMO_ID" ]]; then - echo "## WIPE edge01 demo rows (scoped to $EDGE_DEMO_ID), FK-child-first ..." - # NOTE: edge01 FKs are ON DELETE CASCADE for most child rows; the explicit +if [[ -n "$TARGET_DEMO_ID" ]]; then + echo "## WIPE target demo rows (scoped to $TARGET_DEMO_ID), FK-child-first ..." + # NOTE: target FKs are ON DELETE CASCADE for most child rows; the explicit # child-first deletes below are belt-and-braces (some child tables hang off # medications/mood_entries, not the user) and keep the operation transparent. psql_dst < '$DEMO_USER_ID'); \"" | tr -d '[:space:]')" echo " non-demo user_id rows across carried tables = $FOREIGN_HITS" -echo " NOTE: this counts EVERY non-demo row in these tables on edge01, not just" +echo " NOTE: this counts EVERY non-demo row in these tables on the target, not just" echo " what this script loaded. On a clean demo DB it should be 0. If the" -echo " edge01 demo DB legitimately holds other demo-only accounts, scope" +echo " target demo DB legitimately holds other demo-only accounts, scope" echo " this assertion to the rows this run inserted before treating >0 as fatal." if [[ "$FOREIGN_HITS" != "0" ]]; then - echo "WARNING: $FOREIGN_HITS non-demo rows exist in carried tables on edge01." + echo "WARNING: $FOREIGN_HITS non-demo rows exist in carried tables on the target." echo " Inspect before serving the demo (see note above)." fi echo -echo "HARD assertion 3: the demo user exists on edge01 with role=ADMIN and the apps01 id." +echo "HARD assertion 3: the demo user exists on the target with role=ADMIN and the source id." psql_dst "-c \"SELECT id, username, role, telegram_enabled FROM users WHERE id = '$DEMO_USER_ID';\"" echo echo "==============================================================" echo " DONE. Review the counts above. Demo user is role=ADMIN; the" -echo " app's DEMO_MODE must remain ON on edge01 so all mutations are" +echo " app's DEMO_MODE must remain ON on the target so all mutations are" echo " blocked at the proxy layer while admin pages stay viewable." echo "==============================================================" diff --git a/scripts/seed-demo-v15.sql b/scripts/seed-demo-v15.sql index a15ca2411..960a8d5ac 100644 --- a/scripts/seed-demo-v15.sql +++ b/scripts/seed-demo-v15.sql @@ -1,7 +1,7 @@ -- v1.5.0 demo seed — sliding-window, NOW()-anchored, full iOS+Withings story. -- -- The pre-v1.5 demo seed shipped only WEIGHT / BLOOD_PRESSURE / PULSE / --- BODY_FAT / SLEEP / STEPS — and the demo DB on edge-01 had drifted to +-- BODY_FAT / SLEEP / STEPS, and the demo DB had drifted to -- a static snapshot whose last reading was ten days old. For the iOS -- public-beta cut we need a fresh picture: data through today, every -- type the dashboard tiles can render, a source mix that shows the @@ -17,8 +17,8 @@ -- falls back to live SQL (the slim rollup-tier reader) and the boot -- backfill repopulates on the next entrypoint sweep. -- --- Usage on edge-01: --- docker exec -i agos0oo88gsgg88kcg4swosw \ +-- Usage on the demo host: +-- docker exec -i \ -- psql -U healthlog -d healthlog < seed-demo-v15.sql BEGIN;