Merge pull request #806 from MBombeck/fix/demo-published-credentials

Write the published demo password on every migration
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-21 13:28:34 +02:00
commit 5d3d512e82
3 files changed
+133 -1

No files matched your search

@@ -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);
});
});
+11 -1
View File
@@ -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`,
+49
View File
@@ -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"