diff --git a/scripts/__tests__/demo-published-password-guard.test.ts b/scripts/__tests__/demo-published-password-guard.test.ts new file mode 100644 index 000000000..e9167a684 --- /dev/null +++ b/scripts/__tests__/demo-published-password-guard.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** + * The demo password is published in the README so anyone can sign in to + * 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 + * + * 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 + * credentials the README advertises stopped working, and nothing noticed until + * a visitor filed a bug. These assertions make the two copies move together. + */ + +const REPO_ROOT = join(__dirname, "..", ".."); +const SEED = join(REPO_ROOT, "scripts", "seed-demo.ts"); +const MIGRATE = join(REPO_ROOT, "scripts", "migrate-demo-edge01.sh"); + +/** Every Argon2id hash literal in a file, in source order. */ +function argon2Hashes(path: string): string[] { + const source = readFileSync(path, "utf8"); + return source.match(/\$argon2id\$[^"'\s]+/g) ?? []; +} + +describe("published demo password", () => { + it("is the same hash in the seed script and the edge01 migration", () => { + const seeded = argon2Hashes(SEED); + const migrated = argon2Hashes(MIGRATE); + + // An empty match set would make every assertion below vacuously true — + // the failure mode this repo keeps rediscovering. Demand a hit first. + expect(seeded.length).toBeGreaterThan(0); + expect(migrated.length).toBeGreaterThan(0); + + // Each file states the hash exactly once, so a stray second copy (the + // thing that lets the two drift again) is a failure in its own right. + expect(new Set(seeded).size).toBe(1); + expect(new Set(migrated).size).toBe(1); + + expect(migrated[0]).toBe(seeded[0]); + }); + + it("is written by the migration rather than carried from the source", () => { + const script = readFileSync(MIGRATE, "utf8"); + + // The overwrite must exist... + expect(script).toMatch( + /UPDATE users SET password_hash = '\$\{?PUBLIC_DEMO_PASSWORD_HASH\}?'/, + ); + + // ...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", + ); + }); + + it("keeps the overwrite off the ssh command line", () => { + const script = readFileSync(MIGRATE, "utf8"); + + // The hash carries '$argon2id', '$v', '$m', '$t', '$p'. Interpolated into + // a double-quoted remote command those expand to empty strings and the + // stored hash matches nothing. It has to travel on stdin. + const sshWithHashInline = new RegExp( + String.raw`ssh\s+"\$DST_SSH"\s+"[^"]*PUBLIC_DEMO_PASSWORD_HASH`, + ); + expect(script).not.toMatch(sshWithHashInline); + }); +}); diff --git a/scripts/migrate-demo-edge01.README.md b/scripts/migrate-demo-edge01.README.md index 80eac968c..35dd6cd15 100644 --- a/scripts/migrate-demo-edge01.README.md +++ b/scripts/migrate-demo-edge01.README.md @@ -68,7 +68,17 @@ reset to `'disconnected'`), `ai_anthropic_key_encrypted`, Forced flags: `telegram_enabled=false`, `role='ADMIN'`. -**Kept:** `password_hash` (demo login works), `avatar_bytes` / +**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`, diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index 4ecf556c3..e91c66d92 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -102,6 +102,23 @@ readonly SRC_DB_CONTAINER readonly PG_USER="healthlog" readonly PG_DB="healthlog" +# Argon2id hash of the demo password the README publishes ("demo123demo123"). +# Not a secret: it is printed in the README so anyone can sign in to the demo, +# and it is the same literal that scripts/seed-demo.ts writes on a fresh seed. +# The transfer still carries the source column, but the load is followed by an +# explicit overwrite with this literal (see "FORCE THE PUBLISHED DEMO PASSWORD" +# below). The source is the operator's own instance and its demo password can +# be rotated at any time; carrying it across silently invalidated the published +# credentials once already (public issue #805). Overwriting afterwards makes the +# documented login true by construction after every migration. +# +# The overwrite travels on psql's STDIN, never inside the ssh command string: +# the hash contains '$' segments that a remote shell would expand away. +# +# scripts/__tests__/demo-published-password-guard.test.ts pins this literal to +# the one in scripts/seed-demo.ts, so editing one without the other fails. +readonly PUBLIC_DEMO_PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI' + # 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() { @@ -466,6 +483,38 @@ copy_table() { copy_table "users" "$USERS_SELECT" \ "users WHERE id = '$DEMO_USER_ID'" "$USERS_COLS" +# ---------------------------------------------------------------------------- +# FORCE THE PUBLISHED DEMO PASSWORD +# ---------------------------------------------------------------------------- +# The row just loaded carries the SOURCE account's password_hash. The source is +# the operator's own instance, so that hash is whatever the operator last set — +# not necessarily the password the README publishes. Overwrite it here so the +# documented demo login works after every migration, not just the first one. +# +# The statement goes in on psql's STDIN. It must NOT be interpolated into the +# ssh command string: the Argon2id hash contains '$argon2id', '$v', '$m', '$t' +# and '$p' segments, and a remote shell would expand each of those to an empty +# string and store a mangled hash that no password can ever match. +echo +echo "## FORCE published demo password on the loaded row ..." +printf '%s\n' \ + "UPDATE users SET password_hash = '$PUBLIC_DEMO_PASSWORD_HASH' WHERE id = '$DEMO_USER_ID';" \ + | ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \ + psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB" + +# Prove the stored hash is byte-for-byte the published one. A mangled or +# carried-over hash fails here instead of at some visitor's login screen. +PW_OK="$(printf '%s\n' \ + "SELECT count(*) FROM users WHERE id = '$DEMO_USER_ID' AND password_hash = '$PUBLIC_DEMO_PASSWORD_HASH';" \ + | ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \ + psql -X -tA -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB" | tr -d '[:space:]')" +if [[ "$PW_OK" != "1" ]]; then + echo "ABORT/FAIL: demo password_hash on edge01 is not the published literal (matched rows: $PW_OK)." + echo " The demo login would be broken. Fix before serving the demo." + exit 1 +fi +echo " demo password_hash matches the published literal." + # 2. measurements (live rows only). copy_table "measurements" "$MEAS_COLS" \ "measurements WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MEAS_COLS"