#!/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. # # ============================================================================ # 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 # real-operator data. The ONLY user copied is: # # $DEMO_USER_ID (the single demo account, supplied via env) # # ABSOLUTE INVARIANTS enforced by this script: # 1. ZERO rows from any other user are transferred. Every source SELECT is # scoped to the demo user id (directly via user_id, or join-scoped via a # child FK that resolves back to the demo's own parent rows). A PRE-FLIGHT # guard counts any row in each carried set whose ownership resolves to a # user other than the demo id and ABORTS if the count is non-zero. # 2. A protected account id ($PROTECT_USER_ID, e.g. the operator's real # account) must NEVER appear in any transferred row. A POST-LOAD verification # asserts that id is absent from every carried table, and that every # user_id-bearing carried table contains ONLY the demo id. # 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). # # The transfer mechanism per table is a scoped server-side COPY piped # host-to-host: # # ssh "$SRC_SSH" " -c \"\\copy (SELECT FROM WHERE ) # TO STDOUT (FORMAT csv, HEADER false)\"" # | ssh "$DST_SSH" " -c \"\\copy () # FROM STDIN (FORMAT csv, HEADER false)\"" # # Explicit column lists are used on BOTH ends so: # - secret columns can be omitted / NULLed, # - column order is pinned (independent of physical table layout), # - 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. # # 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). # # ---------------------------------------------------------------------------- # EXCLUDED ENTIRELY (intentionally NOT copied) # ---------------------------------------------------------------------------- # - Auth / session / device surfaces: sessions, passkeys, auth_challenges, # api_tokens, refresh_tokens, devices, push_subscriptions, push_attempts, # notification_channels, notification_preferences. # - Integration / OAuth: withings_connections, whoop_connections, # 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: # 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, medication_compliance_rollups, strain_trimp_cache, # cycle_predictions) — these REGENERATE on edge01. # - 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; # carried link rows reference these GLOBAL ids only (see guards below). # set -euo pipefail # ============================================================================ # CONFIG # ============================================================================ # All instance-specific values come from the environment at runtime — NO real # account ids, host aliases, or container identifiers are baked into this file # (it lives in a public repo; see the project's voice/privacy rules). # DEMO_USER_ID — the single user id to migrate (required) # PROTECT_USER_ID — an id that must NEVER appear in the result (optional guard; # e.g. the operator's real account). Post-load asserts absence. # SRC_SSH / DST_SSH — ssh aliases for the source + target hosts (required) # SRC_DB_CONTAINER_PATTERN — grep pattern to resolve the source DB container (required) # DST_DB_CONTAINER — target DB container (optional; auto-resolved if unset) readonly DEMO_USER_ID="${DEMO_USER_ID:?set DEMO_USER_ID to the user id to migrate}" readonly REAL_USER_ID="${PROTECT_USER_ID:-__no_protect_id__}" readonly SRC_SSH="${SRC_SSH:?set SRC_SSH to the source host ssh alias}" readonly DST_SSH="${DST_SSH:?set DST_SSH to the target host ssh alias}" # Source DB container — resolved dynamically from SRC_DB_CONTAINER_PATTERN (the # Coolify container name carries a rotating suffix, e.g. db--). readonly SRC_DB_CONTAINER_PATTERN="${SRC_DB_CONTAINER_PATTERN:?set SRC_DB_CONTAINER_PATTERN (a grep -E pattern for the source DB container name)}" SRC_DB_CONTAINER="$(ssh "$SRC_SSH" "docker ps --format '{{.Names}}' | grep -E '$SRC_DB_CONTAINER_PATTERN' | head -1" 2>/dev/null | tr -d '[:space:]')" if [[ -z "$SRC_DB_CONTAINER" ]]; then echo "ABORT: could not resolve source DB container (pattern: $SRC_DB_CONTAINER_PATTERN)."; exit 1; fi readonly SRC_DB_CONTAINER readonly PG_USER="healthlog" readonly PG_DB="healthlog" # psql invocation builders. -v ON_ERROR_STOP=1 makes any SQL error a non-zero # exit so `set -e` aborts the pipeline. -X skips ~/.psqlrc. psql_src() { # Usage: psql_src "" ssh "$SRC_SSH" "docker exec -i $SRC_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB $*" } # DST container name is resolved at runtime into DST_DB_CONTAINER (see below), # so psql_dst is defined after resolution. echo "==============================================================" echo " HealthLog demo-user migration apps01 -> edge01" 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 # 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 ..." 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/^/ /' # Only the demo Postgres is the correct target. Pick the single match or abort. DST_DB_CONTAINER_COUNT="$(printf '%s\n' "$DST_DB_CONTAINER" | grep -c . || true)" if [[ "$DST_DB_CONTAINER_COUNT" -ne 1 ]]; then echo echo "ABORT: expected exactly one Postgres container on $DST_SSH, found $DST_DB_CONTAINER_COUNT." echo " Set DST_DB_CONTAINER explicitly and re-run, e.g.:" echo " DST_DB_CONTAINER=db-xxxx $0" exit 1 fi 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" # Confirm the psql user/db actually work on the resolved container before we # touch anything (fail fast on a wrong container guess). 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 ..." 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. # ============================================================================ 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." 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." exit 1 fi fi readonly EDGE_DEMO_ID # ============================================================================ # PRE-FLIGHT GUARD (on apps01 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. # ============================================================================ echo echo "## PRE-FLIGHT guard: assert source SELECTs are demo-scoped (each must be 0)" preflight() { local label="$1" sql="$2" local n n="$(psql_src "-tA -c \"$sql\"" | tr -d '[:space:]')" printf ' %-40s leaked-rows=%s\n' "$label" "$n" if [[ "$n" != "0" ]]; then echo "ABORT: pre-flight guard '$label' returned $n rows owned by a non-demo user." exit 1 fi } # user_id-bearing tables: assert the scoped set contains no foreign user_id. # (The predicate is the same one the COPY SELECT uses; this is a self-check # that the scope expression cannot admit a non-demo row.) preflight "measurements" "SELECT count(*) FROM measurements WHERE deleted_at IS NULL AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "cycle_profiles" "SELECT count(*) FROM cycle_profiles WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "menstrual_cycles" "SELECT count(*) FROM menstrual_cycles WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "cycle_day_logs" "SELECT count(*) FROM cycle_day_logs WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "mood_entries" "SELECT count(*) FROM mood_entries WHERE deleted_at IS NULL AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "medications" "SELECT count(*) FROM medications WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "medication_intake_events" "SELECT count(*) FROM medication_intake_events WHERE deleted_at IS NULL AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "medication_side_effects" "SELECT count(*) FROM medication_side_effects WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "medication_inventory_items" "SELECT count(*) FROM medication_inventory_items WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "personal_records" "SELECT count(*) FROM personal_records WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "user_achievements" "SELECT count(*) FROM user_achievements WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "consent_receipts" "SELECT count(*) FROM consent_receipts WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" preflight "audit_logs(insights)" "SELECT count(*) FROM audit_logs WHERE action LIKE 'insights.%' AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';" # Join-scoped child tables: assert the child's parent resolves to the demo user # (i.e. no child row in the carried set whose parent is owned by another user). preflight "medication_schedules(join)" \ "SELECT count(*) FROM medication_schedules s JOIN medications m ON m.id = s.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';" preflight "medication_dose_changes(join)" \ "SELECT count(*) FROM medication_dose_changes d JOIN medications m ON m.id = d.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';" preflight "medication_inventory_events(join)" \ "SELECT count(*) FROM medication_inventory_events e JOIN medications m ON m.id = e.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';" preflight "reminder_phase_configs(join)" \ "SELECT count(*) FROM reminder_phase_configs c JOIN medications m ON m.id = c.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';" preflight "cycle_symptom_links(join)" \ "SELECT count(*) FROM cycle_symptom_links l JOIN cycle_day_logs d ON d.id = l.day_log_id WHERE d.user_id = '$DEMO_USER_ID' AND d.user_id <> '$DEMO_USER_ID';" preflight "mood_entry_tag_links(join)" \ "SELECT count(*) FROM mood_entry_tag_links l JOIN mood_entries e ON e.id = l.mood_entry_id WHERE e.user_id = '$DEMO_USER_ID' AND e.user_id <> '$DEMO_USER_ID';" # 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 # 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 " Carry mood_tags (user-scoped) before mood_entry_tag_links, or skip the links." exit 1 fi # Defensive: assert every carried link points at a GLOBAL tag (user_id IS NULL). preflight "mood_entry_tag_links(non-global tag)" \ "SELECT count(*) FROM mood_entry_tag_links l JOIN mood_entries e ON e.id = l.mood_entry_id JOIN mood_tags t ON t.id = l.mood_tag_id WHERE e.user_id = '$DEMO_USER_ID' AND t.user_id IS NOT NULL;" 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) # 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 # 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 <" "" "" "" local table="$1" select_expr="$2" from_where="$3" dst_cols="$4" echo echo "## TRANSFER $table ..." ssh "$SRC_SSH" "docker exec -i $SRC_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB \ -c \"\\copy (SELECT $select_expr FROM $from_where) TO STDOUT (FORMAT csv, HEADER false)\"" \ | ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB \ -c \"\\copy $table($dst_cols) FROM STDIN (FORMAT csv, HEADER false)\"" local n n="$(psql_dst "-tA -c \"SELECT count(*) FROM $table;\"" | tr -d '[:space:]' || echo '?')" echo " loaded; edge01 $table total rows now: $n" } # ============================================================================ # LOAD in FK-parent-first order. # ============================================================================ # 1. users — the ONE row. copy_table "users" "$USERS_SELECT" \ "users WHERE id = '$DEMO_USER_ID'" "$USERS_COLS" # 2. measurements (live rows only). copy_table "measurements" "$MEAS_COLS" \ "measurements WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MEAS_COLS" # 3. cycle_profiles copy_table "cycle_profiles" "$CYCLE_PROFILE_COLS" \ "cycle_profiles WHERE user_id = '$DEMO_USER_ID'" "$CYCLE_PROFILE_COLS" # 4. menstrual_cycles copy_table "menstrual_cycles" "$MENSTRUAL_CYCLE_COLS" \ "menstrual_cycles WHERE user_id = '$DEMO_USER_ID'" "$MENSTRUAL_CYCLE_COLS" # 5. cycle_day_logs (NULL the two encrypted columns). copy_table "cycle_day_logs" "$CYCLE_DAY_LOG_SELECT" \ "cycle_day_logs WHERE user_id = '$DEMO_USER_ID'" "$CYCLE_DAY_LOG_COLS" # 6. cycle_symptom_links (join-scoped to demo's day logs). copy_table "cycle_symptom_links" "$CYCLE_SYMPTOM_LINK_COLS" \ "cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id = '$DEMO_USER_ID')" \ "$CYCLE_SYMPTOM_LINK_COLS" # 7. mood_entries (live rows only). copy_table "mood_entries" "$MOOD_ENTRY_COLS" \ "mood_entries WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MOOD_ENTRY_COLS" # 8. mood_entry_tag_links (join-scoped; pre-flight asserted all tags are GLOBAL). copy_table "mood_entry_tag_links" "$MOOD_ENTRY_TAG_LINK_COLS" \ "mood_entry_tag_links WHERE mood_entry_id IN (SELECT id FROM mood_entries WHERE user_id = '$DEMO_USER_ID')" \ "$MOOD_ENTRY_TAG_LINK_COLS" # 9. medications copy_table "medications" "$MEDICATION_COLS" \ "medications WHERE user_id = '$DEMO_USER_ID'" "$MEDICATION_COLS" # 10. medication_schedules (join-scoped). copy_table "medication_schedules" "$MED_SCHEDULE_COLS" \ "medication_schedules WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \ "$MED_SCHEDULE_COLS" # 11. medication_intake_events (live rows only). copy_table "medication_intake_events" "$MED_INTAKE_COLS" \ "medication_intake_events WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MED_INTAKE_COLS" # 12. medication_dose_changes (join-scoped). copy_table "medication_dose_changes" "$MED_DOSE_CHANGE_COLS" \ "medication_dose_changes WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \ "$MED_DOSE_CHANGE_COLS" # 13. medication_inventory_items (user-scoped). copy_table "medication_inventory_items" "$MED_INV_ITEM_COLS" \ "medication_inventory_items WHERE user_id = '$DEMO_USER_ID'" "$MED_INV_ITEM_COLS" # 14. medication_inventory_events (join-scoped). copy_table "medication_inventory_events" "$MED_INV_EVENT_COLS" \ "medication_inventory_events WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \ "$MED_INV_EVENT_COLS" # 15. medication_side_effects (user-scoped). copy_table "medication_side_effects" "$MED_SIDE_EFFECT_COLS" \ "medication_side_effects WHERE user_id = '$DEMO_USER_ID'" "$MED_SIDE_EFFECT_COLS" # 16. reminder_phase_configs (join-scoped). copy_table "reminder_phase_configs" "$REMINDER_PHASE_COLS" \ "reminder_phase_configs WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \ "$REMINDER_PHASE_COLS" # 17. personal_records (user-scoped). copy_table "personal_records" "$PERSONAL_RECORD_COLS" \ "personal_records WHERE user_id = '$DEMO_USER_ID'" "$PERSONAL_RECORD_COLS" # 18. user_achievements (user-scoped). copy_table "user_achievements" "$USER_ACHIEVEMENT_COLS" \ "user_achievements WHERE user_id = '$DEMO_USER_ID'" "$USER_ACHIEVEMENT_COLS" # 19. consent_receipts (user-scoped — carries the ai_full consent). copy_table "consent_receipts" "$CONSENT_RECEIPT_COLS" \ "consent_receipts WHERE user_id = '$DEMO_USER_ID'" "$CONSENT_RECEIPT_COLS" # 20. audit_logs — only the plaintext AI insight assessment rows. copy_table "audit_logs" "$AUDIT_LOG_COLS" \ "audit_logs WHERE user_id = '$DEMO_USER_ID' AND action LIKE 'insights.%'" "$AUDIT_LOG_COLS" # ============================================================================ # POST-LOAD VERIFICATION (on edge01). # - per-table demo-user row counts (informational), # - HARD assertion: the REAL operator id appears in NO carried table, # - HARD assertion: every user_id-bearing carried table contains ONLY the # demo id. # ============================================================================ echo echo "==============================================================" echo "## POST-LOAD verification on edge01" echo "==============================================================" echo echo "Per-table demo row counts (user_id = $DEMO_USER_ID):" psql_dst < '$DEMO_USER_ID') + (SELECT count(*) FROM cycle_profiles WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM menstrual_cycles WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM cycle_day_logs WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM mood_entries WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM medications WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM medication_intake_events WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM medication_inventory_items WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM medication_side_effects WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM personal_records WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM user_achievements WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM consent_receipts WHERE user_id <> '$DEMO_USER_ID') + (SELECT count(*) FROM audit_logs WHERE user_id <> '$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 " 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 " 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 " 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." psql_dst "-c \"SELECT id, username, role, telegram_enabled, mood_log_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 " blocked at the proxy layer while admin pages stay viewable." echo "=============================================================="