@bongos/core 1.19.888 → 1.19.889

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.888",
6
- "core_contract": "1.19.888",
7
- "source_commit": "7ccf0b2f4cc7f7bafb93762cbd5cfb9ed37cf546",
5
+ "core_version": "1.19.889",
6
+ "core_contract": "1.19.889",
7
+ "source_commit": "b7871cd73a1ce5f73988d99ef63101a6a2d23050",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-19T20:57:53.725Z",
9
+ "built_at": "2026-09-19T21:23:27.620Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 497,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2266,
14
+ "functional_verbatim": 2269,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2789,
20
- "tree_sha256": "42c331016e53b14ca5416a325790e2846ea0ee2c6a24177222b823a1d2977b9c",
19
+ "file_count": 2792,
20
+ "tree_sha256": "b7b8bc9e53808070c5d46a211481ceef0dea2c901bc79e2b0dc0871e60c362b1",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2607,7 +2607,7 @@
2607
2607
  {
2608
2608
  "path": "docs/module-api-changelog.md",
2609
2609
  "mode": "0000644",
2610
- "sha256": "7f60dfa2c479fecbd5a81de040689b9ff6f9d2aa5a907f9a7fcf86b77b476fea"
2610
+ "sha256": "01a90a40951ae88d4c36c5fabbaf3debc0e02bcaa9fa8a7b70e247aa5d61d236"
2611
2611
  },
2612
2612
  {
2613
2613
  "path": "docs/modules-contract.md",
@@ -2742,7 +2742,7 @@
2742
2742
  {
2743
2743
  "path": "docs/recipes/gds-db-backup.md",
2744
2744
  "mode": "0000644",
2745
- "sha256": "b3d6a3e3a400bf593b0f2d671f35cb1a60279f495c8763d311f8cdb68a6d603a"
2745
+ "sha256": "d3e628bc627cb29874e977bf2e93c6557dc72a210506c22515a983f4f513d627"
2746
2746
  },
2747
2747
  {
2748
2748
  "path": "docs/recipes/instance-deploy.md",
@@ -3699,6 +3699,11 @@
3699
3699
  "mode": "0000644",
3700
3700
  "sha256": "4f35f87050a532f26b7c9cd0a9bc6f298ab5c0e15eb1c36ddcdd4d261201291d"
3701
3701
  },
3702
+ {
3703
+ "path": "migrations/core_249_session_token_hash.sql",
3704
+ "mode": "0000644",
3705
+ "sha256": "ae3e405c7e59fbc13e23ae2a4ba945176f2369a150c997d9b8134e49991f273b"
3706
+ },
3702
3707
  {
3703
3708
  "path": "modules/agents/CLAUDE.md",
3704
3709
  "mode": "0000644",
@@ -7697,12 +7702,12 @@
7697
7702
  {
7698
7703
  "path": "package-lock.json",
7699
7704
  "mode": "0000644",
7700
- "sha256": "d3b1b9f84e1713ff7815c19a9bd02b6c4de4dd3b44a5ede68ff56fbd5ad0cfcd"
7705
+ "sha256": "d1348f68cd79ad102a381e3d7f90b784d9df605e859269ede84ad859cc831deb"
7701
7706
  },
7702
7707
  {
7703
7708
  "path": "package.json",
7704
7709
  "mode": "0000644",
7705
- "sha256": "4cd9682e13a9a2e1e358c7bd500286378e7b5b8ceb1bb284a925b91ab8de2a08"
7710
+ "sha256": "36e895c05e2aed3f97ce34ae3b3f73b0492fff7185077519068bc69dda6a8fd6"
7706
7711
  },
7707
7712
  {
7708
7713
  "path": "public-docs/index.html",
@@ -8132,7 +8137,7 @@
8132
8137
  {
8133
8138
  "path": "scripts/gds/db-backup-offsite.sh",
8134
8139
  "mode": "0000644",
8135
- "sha256": "f455e4a6fdd69f0105104752c86099e3a17dad7b4802fa715191cedab9762cd2"
8140
+ "sha256": "97f52f6aff95e1c81e069c1d4228eecb514af6068c810441465b5f5b7ee0f2ce"
8136
8141
  },
8137
8142
  {
8138
8143
  "path": "scripts/gds/demote.js",
@@ -8807,7 +8812,7 @@
8807
8812
  {
8808
8813
  "path": "scripts/gds/run-unit-tests.js",
8809
8814
  "mode": "0000644",
8810
- "sha256": "8227fe5256b530d22192cd86f3624d52cc1dc34ac82099a8bb9e3d6a28482eb9"
8815
+ "sha256": "a40a90ba6255820130aa6a94ddc314b643177e4a153750dc6290550a75e1a4e8"
8811
8816
  },
8812
8817
  {
8813
8818
  "path": "scripts/gds/runner-drift.js",
@@ -9447,12 +9452,12 @@
9447
9452
  {
9448
9453
  "path": "src/bongos/db-kernel.js",
9449
9454
  "mode": "0000644",
9450
- "sha256": "1fffc39fb9b70cd009d7d239729a9ff329cd41ca1f35b50b7c9e6b89aa83624e"
9455
+ "sha256": "dd16328ac569aa8c6af09b2700957634e2c6d368d291b015a74a8dc452dc340a"
9451
9456
  },
9452
9457
  {
9453
9458
  "path": "src/bongos/db.js",
9454
9459
  "mode": "0000644",
9455
- "sha256": "e88c470ed9399be2930ec2d925e0864c30e355bf20d89486fb57dbe7561f6d78"
9460
+ "sha256": "def84475a0470dafb894e7ca3f96f9db27cea78964f90ac3e81ed748fe9efa48"
9456
9461
  },
9457
9462
  {
9458
9463
  "path": "src/bongos/github-repos.js",
@@ -9707,7 +9712,7 @@
9707
9712
  {
9708
9713
  "path": "src/module-api.js",
9709
9714
  "mode": "0000644",
9710
- "sha256": "ae3867dc81f944026ce67bae25ca927232b84accc4ed03d4ef45798526c72c46"
9715
+ "sha256": "541b09991f4858b3255f250ef7e52e86b0d561ec69d058e74bc218fe1a104932"
9711
9716
  },
9712
9717
  {
9713
9718
  "path": "src/module-loader/catalog.js",
@@ -10092,7 +10097,7 @@
10092
10097
  {
10093
10098
  "path": "tests/auth_session_expiry_db.mjs",
10094
10099
  "mode": "0000644",
10095
- "sha256": "880cbfa0304ab037db980f22471a2b46bcdbad0e7dca57af359402dbb068d6a4"
10100
+ "sha256": "89730864ff5aa36271582c7e2717bf434bc84cbc20b615026005a1ef662ff31c"
10096
10101
  },
10097
10102
  {
10098
10103
  "path": "tests/auth_session_ttl.mjs",
@@ -11357,7 +11362,7 @@
11357
11362
  {
11358
11363
  "path": "tests/government_live_proof.mjs",
11359
11364
  "mode": "0000644",
11360
- "sha256": "6d829af6ef71bfa744a4ff457a4766b5cce021f317661579b06ecfc14298a523"
11365
+ "sha256": "ca29425b30e726e6a7e939b008f0e5ca71877fa88fdcbfe1bf6356482f47c1e8"
11361
11366
  },
11362
11367
  {
11363
11368
  "path": "tests/government_live_proof_shape.mjs",
@@ -12372,7 +12377,7 @@
12372
12377
  {
12373
12378
  "path": "tests/own_session_revoke.mjs",
12374
12379
  "mode": "0000644",
12375
- "sha256": "34ae2846e314ad4ba732a40257fb81f7b4e4be3e81e3b6d1f478c3bb0690f23d"
12380
+ "sha256": "ff24d3753a52a64f426e0602bc2411aad13c1426c2bc0774b7618a1d20cebfad"
12376
12381
  },
12377
12382
  {
12378
12383
  "path": "tests/package_core.mjs",
@@ -13234,6 +13239,16 @@
13234
13239
  "mode": "0000644",
13235
13240
  "sha256": "a630ac72981f230e8aa6609152765c13e8928ac329cb0ff3fe615371f31208d2"
13236
13241
  },
13242
+ {
13243
+ "path": "tests/session_token_hash.mjs",
13244
+ "mode": "0000644",
13245
+ "sha256": "ff4cea59acb15ce383d9346793206865f0aababc5e2f4144c957bb93d45f62b4"
13246
+ },
13247
+ {
13248
+ "path": "tests/session_token_hash_db.mjs",
13249
+ "mode": "0000644",
13250
+ "sha256": "4a757c27f11cde314649e46ab24e51600d24c2f503f1aa7f055cdb4fa60ea6ce"
13251
+ },
13237
13252
  {
13238
13253
  "path": "tests/session_type.mjs",
13239
13254
  "mode": "0000644",
@@ -2250,5 +2250,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2250
2250
  landed since 1.19.886 with no explicit bump. run 35468511941. (task 1002620)
2251
2251
  1.19.888 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2252
2252
  landed since 1.19.887 with no explicit bump. run 35468948342. (task 1002620)
2253
+ 1.19.889 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2254
+ landed since 1.19.888 with no explicit bump. run 35470239834. (task 1002620)
2253
2255
  ---------------------------------------------------------------------------
2254
2256
  ```
@@ -46,8 +46,33 @@ gds-db-offsite.timer ──→ gds-db-offsite.service ──→ db-backup-offsit
46
46
  - **It does not take its own `pg_dump`.** It uploads the newest `/var/backups/gds/<db>-*.sql.gz` that the local nightly already produced. Runs at 05:00 UTC — one hour after the 04:00 local dump — so the copy is same-day-fresh.
47
47
  - **Bucket layout:** objects land under the `gds-offsite/` key prefix in the Space.
48
48
  - **Retention:** 4 weekly copies (`GDS_OFFSITE_KEEP=4` — ~1 month), pruned oldest-first under that prefix. Sub-1 MB each.
49
- - **Credentials** live at `/etc/example/spaces.env` (chmod 600, owned by `lars`), never in the repo (ADR 0022). Required vars: `SPACES_KEY`, `SPACES_SECRET`, `SPACES_BUCKET`, `SPACES_REGION`, `SPACES_ENDPOINT`. systemd injects them via `EnvironmentFile`; a manual run sources the same file.
50
- - **Dependency:** `python3` + the `boto3` module on the droplet. The script fails loudly if either is missing.
49
+ - **Credentials** live at `/etc/example/spaces.env` (chmod 600, owned by `lars`), never in the repo (ADR 0022). Required vars: `SPACES_KEY`, `SPACES_SECRET`, `SPACES_BUCKET`, `SPACES_REGION`, `SPACES_ENDPOINT`, `GDS_OFFSITE_AGE_RECIPIENT`. systemd injects them via `EnvironmentFile`; a manual run sources the same file.
50
+ - **Dependency:** `age`, plus `python3` + the `boto3` module on the droplet. The script fails loudly if any is missing.
51
+ - **The uploaded object is encrypted** (`<db>-<date>.sql.gz.age`) and the retention count applies to those. Any legacy plaintext `.sql.gz` object still under the prefix is **deleted on the next run**, outside the retention count — see below.
52
+
53
+ #### Offsite dumps are encrypted (task [#1003372](https://cloudbongos.com/builders#/task/1003372), audit A1)
54
+
55
+ Until this task the weekly job uploaded the raw `pg_dump`. A dump is not merely "a backup": it is every secret the platform holds, in one file, on storage the platform does not own — OAuth credentials, the GitHub App private key, and (before `core_249`) every builder's live session bearer in plaintext. A breach of the Space, a mis-set bucket ACL or a leaked read-only S3 key was a total compromise with no trace in any log we keep.
56
+
57
+ The dump is now encrypted with [`age`](https://github.com/FiloSottile/age) **to a public key**, so the droplet can produce backups it cannot itself read. That asymmetry is the point: an attacker who owns the droplet already has the live database, but they do not also get the archive of every previous state, and they cannot decrypt what is already in the Space. **Keep the private key off the server.**
58
+
59
+ ```bash
60
+ # ONE-TIME, on a machine that is NOT the server:
61
+ age-keygen -o bongos-backup.key # store safe + OFFLINE — this is the only way back
62
+ # it prints: Public key: age1xxxxxxxx...
63
+ ```
64
+
65
+ Add the **public** key to `/etc/example/spaces.env` as `GDS_OFFSITE_AGE_RECIPIENT=age1...`.
66
+
67
+ > **Without a recipient the job refuses to run**, and does not fall back to a plaintext upload — that fallback is the vulnerability, and a silent one, since a backup that "worked" looks identical either way. A failing timer is visible in `systemctl status gds-db-offsite.timer`; an unencrypted dump in a bucket is visible to nobody until it matters. **If offsite backups stop after this deploy, this is why** — set the recipient.
68
+ >
69
+ > **The first successful run also PURGES every unencrypted `.sql.gz` already in the Space**, unconditionally and outside the retention count. Encrypting from here on does nothing about the copies already up there, and retaining four of them "for safety" would retain the finding. Local nightly dumps on the droplet are untouched, so this removes copies, never the last copy.
70
+
71
+ To restore an offsite object:
72
+
73
+ ```bash
74
+ age -d -i bongos-backup.key <db>-<date>.sql.gz.age | gunzip | psql <db>
75
+ ```
51
76
 
52
77
  > **History:** an earlier task ([#7](https://example.com/builders#/task/7)) shipped a `scripts/backup.sh` + `/etc/cron.d/otb-backup` daily-to-Spaces cron that was forgotten and never worked (empty credentials, no Space, no log). [#415](https://example.com/builders#/task/415) retired it and replaced it with the systemd unit above. If you find any trace of `otb-backup.cron`, `scripts/backup.sh`, or `~/.config/otb/spaces.env` on a host, it's dead — remove it.
53
78
  >
@@ -65,6 +90,9 @@ SPACES_SECRET=...
65
90
  SPACES_BUCKET=otb-gds-backups
66
91
  SPACES_REGION=nyc3
67
92
  SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
93
+ # The age PUBLIC key from `age-keygen`, generated OFF this box (see above).
94
+ # Without it the job refuses to run rather than upload plaintext.
95
+ GDS_OFFSITE_AGE_RECIPIENT=age1...
68
96
  ENV
69
97
  sudo chmod 600 /etc/example/spaces.env
70
98
 
@@ -0,0 +1,103 @@
1
+ -- core_249 — builder_sessions stores a HASH of the bearer, never the bearer
2
+ -- (task 1003372, audit ref A1 from the 2026-08-29 security audit, goal 1000084).
3
+ --
4
+ -- forward-unsafe: drops builder_sessions.token and makes token_hash NOT NULL.
5
+ -- Both are the point of the migration, not incidental to it. The whole finding is
6
+ -- that the plaintext bearer is READABLE AT REST, so a version of this that kept the
7
+ -- column would not fix anything; and the NOT NULL is what stops a future writer
8
+ -- inserting a session no lookup can ever match. Neither is reversible, and neither
9
+ -- should be: rolling back to code that reads `token` finds it gone, but a variant
10
+ -- that merely emptied the column would leave that code finding NULL and signing
11
+ -- everyone out just the same — so the unsafety is in the rollback direction only,
12
+ -- and is identical either way. Forward, nobody is signed out: the backfill below
13
+ -- derives every existing row's hash from the plaintext before the column goes.
14
+ --
15
+ -- WHAT WAS WRONG. `token` held the live bearer in plaintext, NOT NULL UNIQUE and
16
+ -- INDEXED. Every row was a directly replayable credential for the builder it
17
+ -- belonged to — Archons included — so ANY read of this table was a complete
18
+ -- takeover of every account holding a session. That is the exact credential the
19
+ -- whole ADR 0016 trust boundary rests on: the server re-resolves authority per
20
+ -- request from a token, so a token IS the authority. `token_hash` had zero
21
+ -- occurrences repo-wide before this migration.
22
+ --
23
+ -- A read of the table is not hypothetical. The offsite dump was uploaded
24
+ -- unencrypted (same task, `scripts/gds/db-backup-offsite.sh`), and a co-tenant
25
+ -- Postgres role reached this table across tenants until task 1003369 split the
26
+ -- unix accounts. Hashing at rest is what makes those reads worthless rather than
27
+ -- catastrophic.
28
+ --
29
+ -- WHY A BACKFILL AND NOT A FORCED RE-AUTH. The hash is computable from the
30
+ -- plaintext we already hold, so every live session survives the change: the row
31
+ -- keeps working and its holder notices nothing. The alternative — ship the code
32
+ -- and let sessions re-mint — would sign out every builder and every dev box on
33
+ -- deploy day to reach a state this statement reaches for free. Migration 035 and
34
+ -- core_243 both reasoned this way in their own direction: do not put a policy
35
+ -- change on the backs of the people holding live sessions when it lands.
36
+ --
37
+ -- WHY sha256 WITH NO EXTENSION. Postgres 16 (docs/architecture.md) carries
38
+ -- `sha256(bytea)` as a built-in, so the backfill needs no pgcrypto — one less
39
+ -- extension to have installed on a box before a migration will run. It is the
40
+ -- same digest `db-kernel.js` computes in Node, so a row backfilled here and a row
41
+ -- minted by the code resolve identically.
42
+ --
43
+ -- WHY A FAST HASH IS THE RIGHT ONE. A session token is 256 bits of CSPRNG, not a
44
+ -- password: there is no dictionary to run and nothing to slow an attacker down,
45
+ -- so bcrypt/argon would buy nothing and cost a KDF on every authenticated
46
+ -- request. The same reasoning is written out at
47
+ -- `modules/platform-identity/platform-identity.js` (`sha256hex`), which hashes
48
+ -- its client secrets and authorization codes for exactly this reason.
49
+ --
50
+ -- WHY THE PLAINTEXT COLUMN IS DROPPED, NOT NULLED. Nulling it would leave a dead
51
+ -- column that still has to be kept empty by every future writer, and it buys no
52
+ -- rollback safety: rolling back to code that reads `token` finds it NULL and
53
+ -- signs everyone out either way. Dropping it is the honest end state, and the
54
+ -- unique index moves to `token_hash` so the uniqueness guarantee is unbroken.
55
+ --
56
+ -- WHAT THIS DOES NOT CHANGE. Expiry is untouched. ADR 0294 (CLI sessions do not
57
+ -- expire) and ADR 0206 (no absolute ceiling) both stand exactly as written; this
58
+ -- migration does not read, write or reason about `expires_at`. The audit also
59
+ -- asked for refresh-on-use to stop and an absolute lifetime to be added — both are
60
+ -- decisions those two ADRs have already taken the other way, with the owner as
61
+ -- decider, AFTER this audit was filed. Changing them is not this task's to do.
62
+ --
63
+ -- IDEMPOTENT. The whole body is guarded on the plaintext column still existing,
64
+ -- so a second run is a no-op rather than an error about a column that is gone.
65
+
66
+ BEGIN;
67
+
68
+ DO $$
69
+ BEGIN
70
+ IF EXISTS (
71
+ SELECT 1 FROM information_schema.columns
72
+ WHERE table_name = 'builder_sessions' AND column_name = 'token'
73
+ ) THEN
74
+
75
+ ALTER TABLE builder_sessions ADD COLUMN IF NOT EXISTS token_hash text;
76
+
77
+ -- Every live row keeps working: the hash is derived from the bearer already
78
+ -- stored, so no holder is signed out by this deploy.
79
+ UPDATE builder_sessions
80
+ SET token_hash = encode(sha256(token::bytea), 'hex')
81
+ WHERE token_hash IS NULL;
82
+
83
+ -- The invariant the lookup depends on. Enforced only after the backfill has
84
+ -- given every existing row a value.
85
+ ALTER TABLE builder_sessions ALTER COLUMN token_hash SET NOT NULL;
86
+
87
+ -- Carries over `token`'s UNIQUE: two sessions may not share a bearer, and the
88
+ -- hash is what the lookup now keys on, so it needs the same index the
89
+ -- plaintext had for the same reason.
90
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_builder_sessions_token_hash
91
+ ON builder_sessions (token_hash);
92
+
93
+ DROP INDEX IF EXISTS idx_builder_sessions_token;
94
+ ALTER TABLE builder_sessions DROP COLUMN token;
95
+
96
+ END IF;
97
+ END
98
+ $$;
99
+
100
+ COMMENT ON COLUMN builder_sessions.token_hash IS
101
+ 'SHA-256 hex of the bearer token. The plaintext is NEVER stored (core_249, audit A1): it exists only in the response that mints it and in the client that holds it. lookupSession hashes the presented bearer and keys on this column, so a read of this table yields nothing replayable.';
102
+
103
+ COMMIT;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.888",
3
+ "version": "1.19.889",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.888",
9
+ "version": "1.19.889",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.888",
3
+ "version": "1.19.889",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -17,6 +17,36 @@
17
17
  # ADR 0022. The systemd unit injects them via EnvironmentFile; a manual run
18
18
  # sources the same file. Required vars:
19
19
  # SPACES_KEY SPACES_SECRET SPACES_BUCKET SPACES_REGION SPACES_ENDPOINT
20
+ # GDS_OFFSITE_AGE_RECIPIENT
21
+ #
22
+ # ENCRYPTION IS MANDATORY (task 1003372, audit ref A1). Until that task this
23
+ # script uploaded the raw pg_dump to the Space. A database dump is not "a backup"
24
+ # from a security standpoint — it is every secret the platform holds, in one
25
+ # file, on someone else's disk: OAuth credentials, the GitHub App private key,
26
+ # and (before core_249) every builder's live session bearer in plaintext. A
27
+ # breach of the Space, a mis-set bucket ACL, or a leaked read-only S3 key was a
28
+ # total compromise of the instance with no trace in any log the platform keeps.
29
+ #
30
+ # The dump is encrypted to a PUBLIC key (`age`), so this box can produce backups
31
+ # it cannot itself read. That asymmetry is the point: an attacker who owns the
32
+ # droplet gets the live database anyway, but they do not also get the archive of
33
+ # every previous state, and they cannot decrypt what is already in the Space.
34
+ # Keep the private key OFF this machine.
35
+ #
36
+ # One-time setup, on a machine that is NOT the server:
37
+ # age-keygen -o bongos-backup.key # store this somewhere safe and offline
38
+ # # it prints: Public key: age1xxxxxxxx...
39
+ # Then on the server, add to $ENV_FILE:
40
+ # GDS_OFFSITE_AGE_RECIPIENT=age1xxxxxxxx...
41
+ #
42
+ # To restore:
43
+ # age -d -i bongos-backup.key <dump>.sql.gz.age | gunzip | psql <db>
44
+ #
45
+ # WITHOUT A RECIPIENT THIS SCRIPT REFUSES TO RUN. It does not fall back to a
46
+ # plaintext upload: that fallback is the vulnerability, and a silent one, since
47
+ # a backup that "worked" looks identical either way. A failing timer is visible
48
+ # in `systemctl status gds-db-offsite.timer`; an unencrypted dump in a bucket is
49
+ # visible to nobody until it matters.
20
50
  #
21
51
  # Manual one-shot:
22
52
  # <instance-root>/scripts/gds/db-backup-offsite.sh
@@ -72,13 +102,17 @@ ENV_FILE="${GDS_SPACES_ENV:-${CONFIG_DIR_NAME:+/etc/$CONFIG_DIR_NAME/spaces.env}
72
102
  # extract only the five expected KEY=VALUE lines. `read -r k v` with IFS='='
73
103
  # splits on the FIRST '=' only, so base64 secrets containing '/', '+' or '='
74
104
  # survive intact.
75
- if [ -z "${SPACES_KEY:-}" ] && [ -f "$ENV_FILE" ]; then
105
+ # Read the file when EITHER the credentials or the encryption recipient is still
106
+ # unset. Keying this on SPACES_KEY alone would skip the file on a box where
107
+ # systemd injects the S3 vars but the recipient was added to the file later —
108
+ # and the script would then refuse to run while the key it needs sat right there.
109
+ if { [ -z "${SPACES_KEY:-}" ] || [ -z "${GDS_OFFSITE_AGE_RECIPIENT:-}" ]; } && [ -f "$ENV_FILE" ]; then
76
110
  while IFS='=' read -r _k _v; do
77
111
  case "$_k" in
78
- SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT)
112
+ SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT|GDS_OFFSITE_AGE_RECIPIENT)
79
113
  export "$_k=$_v" ;;
80
114
  esac
81
- done < <(grep -E '^(SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT)=' "$ENV_FILE" || true)
115
+ done < <(grep -E '^(SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT|GDS_OFFSITE_AGE_RECIPIENT)=' "$ENV_FILE" || true)
82
116
  fi
83
117
 
84
118
  missing=()
@@ -91,8 +125,30 @@ if [ "${#missing[@]}" -gt 0 ]; then
91
125
  exit 2
92
126
  fi
93
127
 
128
+ # The encryption recipient is as required as the credentials — a dump this box
129
+ # can upload but nobody can decrypt is useless, and a dump it uploads in the
130
+ # clear is the finding this refusal exists to close (task 1003372, audit A1).
131
+ if [ -z "${GDS_OFFSITE_AGE_RECIPIENT:-}" ]; then
132
+ echo "ERROR: GDS_OFFSITE_AGE_RECIPIENT is not set — refusing to ship an unencrypted database dump offsite." >&2
133
+ echo " A dump is every secret this platform holds, in one file, on storage the platform does not own." >&2
134
+ echo "" >&2
135
+ echo " Fix, on a machine that is NOT this server:" >&2
136
+ echo " age-keygen -o bongos-backup.key # keep this file safe and OFFLINE — it is the only way back" >&2
137
+ echo " then add the age1... public key it prints to $ENV_FILE:" >&2
138
+ echo " GDS_OFFSITE_AGE_RECIPIENT=age1..." >&2
139
+ echo "" >&2
140
+ echo " This is deliberately not optional: falling back to a plaintext upload would look identical in the logs." >&2
141
+ exit 2
142
+ fi
143
+
144
+
94
145
  # ---- preconditions ----------------------------------------------------------
95
146
 
147
+ if ! command -v age >/dev/null 2>&1; then
148
+ echo "ERROR: 'age' not on PATH — it is what encrypts the dump before it leaves this box." >&2
149
+ echo " Install with: apt install age (or fetch the static binary from https://github.com/FiloSottile/age/releases)" >&2
150
+ exit 2
151
+ fi
96
152
  if ! command -v python3 >/dev/null 2>&1; then
97
153
  echo "ERROR: python3 not on PATH (needed for the boto3 S3 client)." >&2
98
154
  exit 2
@@ -113,14 +169,38 @@ if [ -z "$LATEST" ]; then
113
169
  fi
114
170
 
115
171
  BASENAME=$(basename "$LATEST")
116
- KEY="${PREFIX}${BASENAME}"
117
172
  BYTES=$(stat -c '%s' "$LATEST")
118
173
 
119
- echo "[$(date -u +%FT%TZ)] gds-db-offsite: archiving ${LATEST} (${BYTES} bytes) → s3://${SPACES_BUCKET}/${KEY}"
174
+ # ---- encrypt before it leaves the box ---------------------------------------
175
+ #
176
+ # To a temp file under the backup dir (same filesystem, so no cross-device copy
177
+ # of plaintext through /tmp) with a 0600 umask, and removed on every exit path
178
+ # including failure — the cleartext dump already lives beside it, so the
179
+ # ciphertext is not sensitive in itself, but leaving files behind on a failing
180
+ # weekly timer fills a disk nobody is watching.
181
+ umask 077
182
+ ENC="$(mktemp "${BACKUP_DIR}/.${BASENAME}.age.XXXXXX")"
183
+ trap 'rm -f "$ENC"' EXIT
184
+
185
+ if ! age -r "$GDS_OFFSITE_AGE_RECIPIENT" -o "$ENC" "$LATEST"; then
186
+ echo "ERROR: age refused to encrypt — is GDS_OFFSITE_AGE_RECIPIENT a valid age1... public key?" >&2
187
+ exit 2
188
+ fi
189
+
190
+ # A zero-byte or missing ciphertext would upload happily and restore to nothing.
191
+ # Checked here rather than trusted, because the failure is silent for a month.
192
+ ENC_BYTES=$(stat -c '%s' "$ENC")
193
+ if [ "$ENC_BYTES" -lt 1 ]; then
194
+ echo "ERROR: encryption produced an empty file — refusing to upload it as a backup." >&2
195
+ exit 2
196
+ fi
197
+
198
+ KEY="${PREFIX}${BASENAME}.age"
199
+ echo "[$(date -u +%FT%TZ)] gds-db-offsite: archiving ${LATEST} (${BYTES} bytes → ${ENC_BYTES} encrypted) → s3://${SPACES_BUCKET}/${KEY}"
120
200
 
121
201
  # ---- upload + prune (single boto3 session) ----------------------------------
122
202
 
123
- GDS_OFFSITE_LOCAL="$LATEST" \
203
+ GDS_OFFSITE_LOCAL="$ENC" \
124
204
  GDS_OFFSITE_KEY="$KEY" \
125
205
  GDS_OFFSITE_KEEP="$KEEP" \
126
206
  GDS_OFFSITE_PREFIX="$PREFIX" \
@@ -151,10 +231,25 @@ s3 = boto3.client(
151
231
  s3.upload_file(local, bucket, objkey)
152
232
  print(f"[gds-db-offsite] uploaded {objkey}")
153
233
 
154
- # Prune: keep the newest `keep` objects under our prefix, delete the rest.
155
234
  resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
235
+ contents = resp.get("Contents", [])
236
+
237
+ # PURGE THE PLAINTEXT ALREADY UP THERE (task 1003372, audit A1). Every `.sql.gz`
238
+ # under this prefix was uploaded unencrypted by an earlier version of this
239
+ # script, and each one is a live copy of every secret the platform held on the
240
+ # day it was taken — including, before core_249, directly replayable session
241
+ # bearers. Encrypting from now on does nothing about them. They are deleted
242
+ # OUTSIDE the retention count, and unconditionally: retaining four of them
243
+ # "for safety" would retain the finding. The local nightly dumps on the droplet
244
+ # are untouched, so this removes copies, never the last copy.
245
+ legacy = [o for o in contents if o["Key"].endswith(".sql.gz")]
246
+ for obj in legacy:
247
+ s3.delete_object(Bucket=bucket, Key=obj["Key"])
248
+ print(f"[gds-db-offsite] PURGED unencrypted legacy dump {obj['Key']}")
249
+
250
+ # Prune: keep the newest `keep` ENCRYPTED objects under our prefix.
156
251
  objects = sorted(
157
- [o for o in resp.get("Contents", []) if o["Key"].endswith(".sql.gz")],
252
+ [o for o in contents if o["Key"].endswith(".sql.gz.age")],
158
253
  key=lambda o: o["LastModified"],
159
254
  )
160
255
  to_delete = objects[:-keep] if len(objects) > keep else []
@@ -163,7 +258,8 @@ for obj in to_delete:
163
258
  print(f"[gds-db-offsite] pruned {obj['Key']}")
164
259
 
165
260
  kept = min(len(objects), keep)
166
- print(f"[gds-db-offsite] ok · {kept} offsite dump(s) retained · pruned {len(to_delete)}")
261
+ print(f"[gds-db-offsite] ok · {kept} offsite dump(s) retained · pruned {len(to_delete)}"
262
+ + (f" · purged {len(legacy)} unencrypted legacy dump(s)" if legacy else ""))
167
263
  PYEOF
168
264
 
169
265
  echo "[$(date -u +%FT%TZ)] gds-db-offsite: done"
@@ -171,6 +171,14 @@ const INTEGRATION = new Set([
171
171
  // real engine can show the CASE guard leaving a NULL alone on an actual use,
172
172
  // which is exactly where the removed timeout would come back.
173
173
  'auth_session_expiry_db',
174
+ // task 1003372 (audit A1, core_249): that hashing the bearer at rest does not
175
+ // sign anybody out. The DB-free sibling (session_token_hash) stands in front of
176
+ // the pool and proves the plaintext never reaches a query — the negative that
177
+ // matters — but it cannot show that Postgres accepts the migration, nor that
178
+ // the digest the backfill computes in SQL equals the one the kernel computes in
179
+ // Node. That mismatch would leave every new session working and every migrated
180
+ // one dead, which is the failure a source-reading test walks straight past.
181
+ 'session_token_hash_db',
174
182
  'scouting_rollup_fanout', 'session_records',
175
183
  // task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
176
184
  // SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
@@ -23,6 +23,7 @@
23
23
 
24
24
  'use strict';
25
25
 
26
+ const crypto = require('node:crypto');
26
27
  const { pool } = require('./pool');
27
28
  const seams = require('../module-seams');
28
29
 
@@ -277,8 +278,37 @@ async function accessStateByLogin(login) {
277
278
  // code agree on one number.
278
279
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
279
280
 
281
+ // sessionTokenHash — the ONE place a bearer becomes the value this table stores
282
+ // (task 1003372, audit A1, migration core_249). SHA-256 hex, matching the
283
+ // migration's `encode(sha256(token::bytea), 'hex')` byte for byte, so a row
284
+ // backfilled in SQL and a row minted here resolve identically.
285
+ //
286
+ // WHY A FAST HASH. A session token is 256 bits of CSPRNG, not a password: there
287
+ // is no dictionary to run, so a KDF would buy nothing and cost real latency on
288
+ // every authenticated request. Same reasoning, written out at length, in
289
+ // modules/platform-identity/platform-identity.js (`sha256hex`).
290
+ //
291
+ // WHY NO timingSafeEqual HERE, THOUGH THE TASK NAMED IT. The pattern the task
292
+ // points at compares a presented secret against one it already fetched by
293
+ // client_id, so the comparison happens in JS and must be constant-time. A session
294
+ // has no second identifier — the bearer IS the lookup key — so the comparison is
295
+ // the indexed `WHERE token_hash = $1` below, and there is no JS compare to make
296
+ // constant-time. Adding a timingSafeEqual after that lookup would compare the
297
+ // hash to itself: vacuously true, and worse than nothing, because it would read
298
+ // as a control that is doing something. The property the task wanted — a guessed
299
+ // token leaks no information about how close it was — holds here because the
300
+ // index is probed with a full digest, never a prefix.
301
+ function sessionTokenHash(token) {
302
+ return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex');
303
+ }
304
+
280
305
  // createSession — mint a builder_sessions row.
281
306
  //
307
+ // THE PLAINTEXT BEARER IS NEVER STORED (task 1003372). `token` is hashed on the
308
+ // way in and the column that held it is gone (core_249). The raw value is
309
+ // returned to the caller — it is their input, handed back so the mint response
310
+ // can carry it to the client — and after that it exists only in the client.
311
+ //
282
312
  // A null/undefined expiresAt means NO EXPIRY (expires_at NULL), which
283
313
  // lookupSession honours as never-expires. That is the CLI's contract (ADR 0294,
284
314
  // restoring 003_pms.sql's original one): a `bongos` sign-in lasts until it is
@@ -290,12 +320,15 @@ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
290
320
  async function createSession({ builderId, token, source, userAgent, expiresAt }) {
291
321
  const effectiveExpiresAt = expiresAt ?? null;
292
322
  const { rows } = await pool.query(
293
- `INSERT INTO builder_sessions (builder_id, token, source, user_agent, expires_at)
323
+ `INSERT INTO builder_sessions (builder_id, token_hash, source, user_agent, expires_at)
294
324
  VALUES ($1, $2, $3, $4, $5)
295
- RETURNING id, builder_id, token, source, created_at, expires_at`,
296
- [builderId, token, source, userAgent ?? null, effectiveExpiresAt]
325
+ RETURNING id, builder_id, source, created_at, expires_at`,
326
+ [builderId, sessionTokenHash(token), source, userAgent ?? null, effectiveExpiresAt]
297
327
  );
298
- return rows[0];
328
+ // `token` is re-attached from the ARGUMENT, never read back from the row — the
329
+ // row no longer holds it. Callers that pass a token and read one back are
330
+ // unchanged; what changed is that the database never saw the plaintext.
331
+ return { ...rows[0], token };
299
332
  }
300
333
 
301
334
  // lookupSession — resolve a live (unrevoked, unexpired) session token to its
@@ -346,8 +379,8 @@ async function lookupSession(token) {
346
379
  b.rank, b.status, b.system_role, b.anthropic_user_id${sessionPermsSelect()}
347
380
  FROM builder_sessions s
348
381
  JOIN builders b ON b.id = s.builder_id
349
- WHERE s.token = $1`,
350
- [token]
382
+ WHERE s.token_hash = $1`,
383
+ [sessionTokenHash(token)]
351
384
  );
352
385
  const row = rows[0];
353
386
  if (!row) return null;
@@ -376,6 +409,9 @@ async function lookupSession(token) {
376
409
  module.exports = {
377
410
  withTx,
378
411
  sessionPermsSelect,
412
+ // Exported for db.js's revokeSession (which is handed a bearer, not an id) and
413
+ // for the tests that assert the plaintext never reaches a query parameter.
414
+ sessionTokenHash,
379
415
  getBuilderById,
380
416
  getFoundingBuilder,
381
417
  listActiveBuilders,
package/src/bongos/db.js CHANGED
@@ -44,6 +44,9 @@ const {
44
44
  // internal uses (setBuilderRank's RANKS.indexOf, etc.) stay byte-identical.
45
45
  RANKS, SESSION_TTL_MS, upsertBuilder, seatFirstAdminIfUnclaimed,
46
46
  getBuilderByGithubId, hasInvitedAccessRequest, createSession, lookupSession,
47
+ // The bearer → stored-value transform (task 1003372, core_249). revokeSession
48
+ // below is the one helper here still handed a raw bearer rather than an id.
49
+ sessionTokenHash,
47
50
  } = require('./db-kernel');
48
51
 
49
52
  // gradePassCount removed with builderRollingAvgGrade (its only core user) — that
@@ -728,10 +731,14 @@ async function hasCliSession(builderId) {
728
731
  // lookupSession — carved to ./db-kernel (ADR 0102). Re-exported above.
729
732
 
730
733
 
734
+ // Takes the raw bearer (its callers are sign-out paths holding a cookie or an
735
+ // Authorization header, not a session id) and matches on the stored hash —
736
+ // core_249 dropped the plaintext column, so the bearer itself is no longer
737
+ // something this table can be searched by.
731
738
  async function revokeSession(token) {
732
739
  await pool.query(
733
- `UPDATE builder_sessions SET revoked_at = now() WHERE token = $1 AND revoked_at IS NULL`,
734
- [token]
740
+ `UPDATE builder_sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`,
741
+ [sessionTokenHash(token)]
735
742
  );
736
743
  }
737
744
 
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.888'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.889'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -67,7 +67,7 @@ await pool.query(`
67
67
  display_name text, avatar_url text, total_credits int DEFAULT 0, rank text DEFAULT 'archon',
68
68
  status text DEFAULT 'active', system_role text, anthropic_user_id text);
69
69
  CREATE TABLE builder_sessions (id bigserial PRIMARY KEY, builder_id bigint REFERENCES builders(id),
70
- token text UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
70
+ token_hash text UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
71
71
  last_used_at timestamptz DEFAULT now(), expires_at timestamptz, revoked_at timestamptz);
72
72
  INSERT INTO builders (github_login) VALUES ('probe');
73
73
  `);
@@ -86,6 +86,11 @@ const t = async (name, fn) => {
86
86
  catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
87
87
  };
88
88
  const row = async (sql, p) => (await client.query(sql, p)).rows[0];
89
+ // core_249: the bearer is no longer a column, so a probe that wants "the row for
90
+ // this token" has to ask by the stored digest. Uses the kernel's OWN helper, so a
91
+ // change to the hash cannot leave this file quietly matching nothing — a mismatch
92
+ // would make every assertion below read a missing row and fail loudly.
93
+ const h = (token) => kernel.sessionTokenHash(token);
89
94
  // refresh-on-use is fire-and-forget: let the UPDATE land before reading it back.
90
95
  const settle = () => new Promise((r) => setTimeout(r, 300));
91
96
 
@@ -110,67 +115,67 @@ await t('lookupSession resolves a CLI bearer to its builder', async () => {
110
115
  await t('after a real use, a CLI session STILL has no expiry (the CASE guard)', async () => {
111
116
  await kernel.lookupSession('cli-tok');
112
117
  await settle();
113
- assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='cli-tok'`)).expires_at, null);
118
+ assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).expires_at, null);
114
119
  });
115
120
 
116
121
  await t('refresh-on-use still bumps last_used_at on that same row', async () => {
117
- const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
118
- await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE token='cli-tok'`);
122
+ const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).last_used_at;
123
+ await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE token_hash='${h('cli-tok')}'`);
119
124
  await kernel.lookupSession('cli-tok');
120
125
  await settle();
121
- const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
126
+ const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).last_used_at;
122
127
  assert.ok(after > new Date(Date.now() - 60_000), `last_used_at did not move: ${after} (was ${before})`);
123
128
  });
124
129
 
125
130
  await t('a web session still rolls its expiry forward (refresh-on-use intact)', async () => {
126
131
  await kernel.lookupSession('web-tok');
127
132
  await settle();
128
- const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token='web-tok'`)).expires_at;
133
+ const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('web-tok')}'`)).expires_at;
129
134
  assert.ok(got !== null && got < thirtyDays, `expected a rolled-forward date below 30d, got ${got}`);
130
135
  });
131
136
 
132
137
  await t('an expired session is still REFUSED (expiry honoured where present)', async () => {
133
- await client.query(`INSERT INTO builder_sessions (builder_id, token, source, expires_at)
134
- VALUES (1,'dead-tok','web', now() - interval '1 hour')`);
138
+ await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, expires_at)
139
+ VALUES (1,'${h('dead-tok')}','web', now() - interval '1 hour')`);
135
140
  assert.equal(await kernel.lookupSession('dead-tok'), null);
136
141
  });
137
142
 
138
143
  await t('a revoked CLI session is still REFUSED', async () => {
139
- await client.query(`INSERT INTO builder_sessions (builder_id, token, source, revoked_at)
140
- VALUES (1,'revoked-tok','cli', now())`);
144
+ await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, revoked_at)
145
+ VALUES (1,'${h('revoked-tok')}','cli', now())`);
141
146
  assert.equal(await kernel.lookupSession('revoked-tok'), null);
142
147
  });
143
148
 
144
149
  // The migration, against rows shaped like the ones live instances actually hold.
145
150
  const migrationSql = readFileSync(MIGRATION, 'utf8');
146
- await client.query(`INSERT INTO builder_sessions (builder_id, token, source, expires_at) VALUES
147
- (1,'old-cli','cli', now() + interval '24 hours'),
148
- (1,'old-web','web', now() + interval '30 days'),
149
- (1,'old-revoked','cli', now() + interval '24 hours')`);
150
- await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE token='old-revoked'`);
151
+ await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, expires_at) VALUES
152
+ (1,'${h('old-cli')}','cli', now() + interval '24 hours'),
153
+ (1,'${h('old-web')}','web', now() + interval '30 days'),
154
+ (1,'${h('old-revoked')}','cli', now() + interval '24 hours')`);
155
+ await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE token_hash='${h('old-revoked')}'`);
151
156
  await client.query(migrationSql);
152
157
 
153
158
  await t('core_243 clears a pre-existing CLI expiry', async () => {
154
- assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-cli'`)).expires_at, null);
159
+ assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-cli')}'`)).expires_at, null);
155
160
  });
156
161
 
157
162
  await t('core_243 leaves a WEB row dated — the source filter is its safety property', async () => {
158
- assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-web'`)).expires_at, null);
163
+ assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-web')}'`)).expires_at, null);
159
164
  });
160
165
 
161
166
  await t('core_243 leaves a revoked row alone (it is the audit trail)', async () => {
162
- assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-revoked'`)).expires_at, null);
167
+ assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-revoked')}'`)).expires_at, null);
163
168
  });
164
169
 
165
170
  await t('core_243 is idempotent — a re-run changes nothing', async () => {
166
171
  await client.query(migrationSql);
167
- assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-cli'`)).expires_at, null);
168
- assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-web'`)).expires_at, null);
172
+ assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-cli')}'`)).expires_at, null);
173
+ assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-web')}'`)).expires_at, null);
169
174
  });
170
175
 
171
176
  // The whole point of the task, stated as a test.
172
177
  await t('a CLI session idle for 21 DAYS still authenticates', async () => {
173
- await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE token='cli-tok'`);
178
+ await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE token_hash='${h('cli-tok')}'`);
174
179
  assert.notEqual(await kernel.lookupSession('cli-tok'), null);
175
180
  });
176
181
 
@@ -42,6 +42,9 @@ if (!process.env.DATABASE_URL) {
42
42
  }
43
43
 
44
44
  const { Pool } = require('pg');
45
+ // The kernel's own bearer → stored-digest transform (core_249), so this fixture
46
+ // cannot drift from how production mints a session.
47
+ const { sessionTokenHash } = require('../src/bongos/db-kernel.js');
45
48
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
46
49
 
47
50
  // ── THE READINESS GUARD, AND WHY ITS SHAPE MATTERS (task 1003091) ────────────
@@ -137,9 +140,12 @@ async function seed() {
137
140
  [ARCHON, THETES]
138
141
  );
139
142
  await q(
140
- `INSERT INTO builder_sessions (builder_id, token, source)
143
+ // core_249: the bearer is stored as its SHA-256, so a fixture session has to
144
+ // be seeded the same way the kernel mints one — otherwise the HTTP calls
145
+ // below authenticate as nobody.
146
+ `INSERT INTO builder_sessions (builder_id, token_hash, source)
141
147
  VALUES ($1,$2,'web'), ($3,$4,'web')`,
142
- [ARCHON, ARCHON_TOKEN, THETES, THETES_TOKEN]
148
+ [ARCHON, sessionTokenHash(ARCHON_TOKEN), THETES, sessionTokenHash(THETES_TOKEN)]
143
149
  );
144
150
  const [idea] = await q(
145
151
  `INSERT INTO idea_inbox (title, body_md, captured_by)
@@ -30,6 +30,7 @@
30
30
  // Run: node tests/own_session_revoke.mjs
31
31
 
32
32
  import { strict as assert } from 'node:assert';
33
+ import crypto from 'node:crypto';
33
34
  import { createRequire } from 'node:module';
34
35
  import http from 'node:http';
35
36
  import { readFileSync } from 'node:fs';
@@ -37,6 +38,13 @@ import { fileURLToPath } from 'node:url';
37
38
  import { dirname, join } from 'node:path';
38
39
 
39
40
  const require = createRequire(import.meta.url);
41
+ // core_249: the bearer is stored and matched as its SHA-256. Computed here with
42
+ // node:crypto rather than imported from db-kernel — requiring the kernel at this
43
+ // point would bind the REAL pool before the fake below is installed in the
44
+ // require cache, and every case would then try to reach Postgres. The two cannot
45
+ // drift unnoticed: tests/session_token_hash.mjs pins the kernel's helper against
46
+ // exactly this expression.
47
+ const sessionTokenHash = (t) => crypto.createHash('sha256').update(String(t), 'utf8').digest('hex');
40
48
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
41
49
 
42
50
  let passed = 0;
@@ -124,7 +132,11 @@ function predicateFor(sql, params) {
124
132
  // would tear it in half and leave a dangling `OR` behind.
125
133
  take(/\(?expires_at IS NULL OR expires_at > now\(\)\)?/,
126
134
  () => (r) => r.expires_at === null || r.expires_at > new Date());
127
- take(/\b(?:s\.)?token = \$(\d+)/, (v) => (r) => r.token === v);
135
+ // core_249: the bearer is matched as its SHA-256, never as itself. The fixture
136
+ // rows below carry `token` as the plaintext a client would present, so the
137
+ // predicate hashes the row rather than the parameter — which also means a
138
+ // regression to `token = $1` stops matching here instead of quietly passing.
139
+ take(/\b(?:s\.)?token_hash = \$(\d+)/, (v) => (r) => sessionTokenHash(r.token) === v);
128
140
  take(/(?<!_)\b(?:s\.)?id = \$(\d+)/, (v) => (r) => String(r.id) === String(v));
129
141
  take(/\bbuilder_id = \$(\d+)/, (v) => (r) => String(r.builder_id) === String(v));
130
142
  take(/\brevoked_at IS NULL/, () => (r) => r.revoked_at === null);
@@ -0,0 +1,139 @@
1
+ // tests/session_token_hash.mjs — core_249 / task 1003372 (audit A1). DB-FREE.
2
+ //
3
+ // The property this file exists to hold is NEGATIVE: the plaintext bearer must
4
+ // never reach the database. A negative is exactly what a green test suite is
5
+ // worst at — the code can stop hashing and every functional assertion still
6
+ // passes, because a system that stores and compares plaintext works perfectly.
7
+ // So this file does not check that sessions resolve (the DB sibling does that);
8
+ // it checks what the kernel HANDS THE POOL, by standing in front of it and
9
+ // reading every query and parameter that goes past.
10
+ //
11
+ // The real-DB sibling `session_token_hash_db.mjs` proves the halves this cannot:
12
+ // that Postgres accepts the migration, that the SQL digest equals the Node one,
13
+ // and that a bearer minted before the migration still authenticates after it.
14
+ // It self-skips without a database, and a skipped case is not a passing one —
15
+ // which is the whole reason this file is not optional.
16
+ //
17
+ // Run: node tests/session_token_hash.mjs
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { readFileSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { dirname, join } from 'node:path';
23
+ import { strict as assert } from 'node:assert';
24
+ import crypto from 'node:crypto';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+
29
+ // Stand in front of the pool BEFORE the kernel binds it, and record everything.
30
+ const seen = [];
31
+ const poolPath = require.resolve('../src/bongos/pool.js');
32
+ require.cache[poolPath] = {
33
+ id: poolPath, filename: poolPath, loaded: true,
34
+ exports: {
35
+ pool: {
36
+ query: async (text, params) => {
37
+ seen.push({ text, params: params || [] });
38
+ return { rows: [], rowCount: 0 };
39
+ },
40
+ },
41
+ },
42
+ };
43
+ const kernel = require('../src/bongos/db-kernel.js');
44
+
45
+ let passed = 0, failed = 0;
46
+ const t = async (name, fn) => {
47
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
48
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
49
+ };
50
+
51
+ const BEARER = 'a-perfectly-ordinary-looking-bearer-token-5f21c9';
52
+ const DIGEST = crypto.createHash('sha256').update(BEARER, 'utf8').digest('hex');
53
+
54
+ await t('sessionTokenHash is SHA-256 hex, pinned against an independent digest', () => {
55
+ assert.equal(kernel.sessionTokenHash(BEARER), DIGEST);
56
+ assert.equal(kernel.sessionTokenHash(BEARER).length, 64);
57
+ assert.match(kernel.sessionTokenHash(BEARER), /^[0-9a-f]{64}$/);
58
+ });
59
+
60
+ await t('it is the SAME digest the migration computes, spelled the SQL way', () => {
61
+ // Not a re-derivation of the value — a check that the migration's expression
62
+ // is the one this hash corresponds to. If someone changes either side to
63
+ // sha512, base64, or a salted form, the two stop agreeing and every session
64
+ // that predates the deploy dies. The DB sibling proves the values match by
65
+ // running both; this catches the edit that causes it.
66
+ const sql = readFileSync(join(here, '..', 'migrations', 'core_249_session_token_hash.sql'), 'utf8');
67
+ assert.match(sql, /encode\(\s*sha256\(\s*token::bytea\s*\)\s*,\s*'hex'\s*\)/,
68
+ 'the backfill no longer computes sha256-hex of the plaintext');
69
+ });
70
+
71
+ await t('THE NEGATIVE: createSession never hands the pool the plaintext', async () => {
72
+ seen.length = 0;
73
+ await kernel.createSession({ builderId: '1', token: BEARER, source: 'cli', userAgent: 'probe', expiresAt: null });
74
+ assert.equal(seen.length, 1, 'expected exactly one query');
75
+ const [q] = seen;
76
+ assert.ok(!q.params.includes(BEARER), 'the plaintext bearer was passed as a query parameter');
77
+ assert.ok(q.params.includes(DIGEST), 'the digest was not passed');
78
+ assert.ok(!q.text.includes('token,') && !/\btoken\b\s*=/.test(q.text),
79
+ 'the statement still names the dropped plaintext column');
80
+ assert.match(q.text, /token_hash/);
81
+ });
82
+
83
+ await t('THE NEGATIVE: lookupSession never hands the pool the plaintext', async () => {
84
+ seen.length = 0;
85
+ await kernel.lookupSession(BEARER);
86
+ const read = seen[0];
87
+ assert.ok(read, 'no query was issued');
88
+ assert.ok(!read.params.includes(BEARER), 'the plaintext bearer was used as the lookup key');
89
+ assert.deepEqual(read.params, [DIGEST]);
90
+ assert.match(read.text, /s\.token_hash = \$1/);
91
+ });
92
+
93
+ await t('THE NEGATIVE: revokeSession never hands the pool the plaintext', async () => {
94
+ seen.length = 0;
95
+ const db = require('../src/bongos/db.js');
96
+ await db.revokeSession(BEARER);
97
+ const q = seen.find((s) => /revoked_at = now\(\)/.test(s.text));
98
+ assert.ok(q, 'no revoke statement was issued');
99
+ assert.ok(!q.params.includes(BEARER), 'revoke still matches on the plaintext bearer');
100
+ assert.deepEqual(q.params, [DIGEST]);
101
+ assert.match(q.text, /token_hash = \$1/);
102
+ });
103
+
104
+ await t('the probe would CATCH a regression — it is not vacuously green', async () => {
105
+ // A negative assertion is worth nothing unless it can fail. This runs the same
106
+ // checks against a deliberately-wrong call (the plaintext passed straight
107
+ // through) and requires them to reject it. Without this, a stub that recorded
108
+ // nothing, or an `includes` against the wrong array, would pass every case
109
+ // above while the kernel stored plaintext.
110
+ seen.length = 0;
111
+ const fakePool = { query: async (text, params) => { seen.push({ text, params }); return { rows: [] }; } };
112
+ await fakePool.query('SELECT 1 FROM builder_sessions WHERE token = $1', [BEARER]);
113
+ const bad = seen[0];
114
+ assert.ok(bad.params.includes(BEARER), 'the recorder is not capturing parameters at all');
115
+ assert.ok(!bad.params.includes(DIGEST), 'the recorder would accept a plaintext query as hashed');
116
+ });
117
+
118
+ await t('the migration drops the plaintext column rather than emptying it', () => {
119
+ const sql = readFileSync(join(here, '..', 'migrations', 'core_249_session_token_hash.sql'), 'utf8');
120
+ assert.match(sql, /ALTER TABLE builder_sessions DROP COLUMN token\b/);
121
+ assert.match(sql, /CREATE UNIQUE INDEX IF NOT EXISTS ux_builder_sessions_token_hash/);
122
+ assert.match(sql, /DROP INDEX IF EXISTS idx_builder_sessions_token/);
123
+ });
124
+
125
+ await t('the migration does NOT touch expiry — ADR 0294 and ADR 0206 stand', () => {
126
+ // The audit also asked for refresh-on-use to stop and an absolute session
127
+ // lifetime to be added. Both were decided the other way, by the owner, AFTER
128
+ // the audit was written: ADR 0294 keeps the roll-forward for rows that have an
129
+ // expiry, and ADR 0206 + ADR 0294 both reject an absolute ceiling by name. A
130
+ // hashing migration that quietly reintroduced either would be the tail wagging
131
+ // the dog, so the absence is asserted rather than left to reading.
132
+ const sql = readFileSync(join(here, '..', 'migrations', 'core_249_session_token_hash.sql'), 'utf8');
133
+ const statements = sql.split('\n').filter((l) => !l.trim().startsWith('--')).join('\n');
134
+ assert.ok(!/expires_at/.test(statements),
135
+ 'core_249 writes expires_at — that belongs to ADR 0294, not to a hashing migration');
136
+ });
137
+
138
+ console.log(`\nsession_token_hash: ${passed} passed, ${failed} failed`);
139
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,193 @@
1
+ // tests/session_token_hash_db.mjs — core_249 / task 1003372 (audit A1, real DB).
2
+ //
3
+ // The bearer stored in `builder_sessions` was plaintext, NOT NULL UNIQUE and
4
+ // INDEXED: every row was a directly replayable credential. This file proves the
5
+ // two properties that fix has to carry, and they pull against each other:
6
+ //
7
+ // 1. NOTHING REPLAYABLE IS LEFT. The plaintext column is gone, and a dump of
8
+ // the table yields no value that authenticates.
9
+ // 2. NOBODY IS SIGNED OUT. A session minted BEFORE the migration still
10
+ // authenticates AFTER it, with the same bearer its holder is still carrying.
11
+ //
12
+ // (2) is the one worth a real database. It is the whole reason the migration
13
+ // backfills instead of letting sessions re-mint, and it can only be proved by
14
+ // running the actual SQL against rows in the actual old shape. A mistake here
15
+ // does not fail loudly in review — it signs out every builder and every dev box
16
+ // on deploy day, including whoever would have to log in to fix it.
17
+ //
18
+ // The sibling `session_token_hash.mjs` is DB-free and pins the hash itself plus
19
+ // the statements' shape. What it cannot see is whether Postgres accepts the
20
+ // migration at all, or whether the digest the migration computes in SQL equals
21
+ // the one the kernel computes in Node — a mismatch that would leave every
22
+ // pre-existing session dead while every new one worked, which is exactly the
23
+ // failure a code-only test reads straight past.
24
+ //
25
+ // Real-DB integration test: self-skips without a reachable Postgres.
26
+ //
27
+ // Run: DATABASE_URL=... node tests/session_token_hash_db.mjs
28
+
29
+ import { createRequire } from 'node:module';
30
+ import { readFileSync } from 'node:fs';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { dirname, join } from 'node:path';
33
+ import { strict as assert } from 'node:assert';
34
+
35
+ const require = createRequire(import.meta.url);
36
+ const { Pool } = require('pg');
37
+
38
+ const here = dirname(fileURLToPath(import.meta.url));
39
+ const MIGRATION = join(here, '..', 'migrations', 'core_249_session_token_hash.sql');
40
+
41
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
42
+ try {
43
+ await pool.query('SELECT 1');
44
+ } catch (e) {
45
+ console.log(`session_token_hash_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
46
+ await pool.end().catch(() => {});
47
+ process.exit(0);
48
+ }
49
+
50
+ const SCHEMA = `tokhash_probe_${process.pid}`;
51
+ await pool.query(`CREATE SCHEMA ${SCHEMA}`);
52
+ await pool.query(`SET search_path TO ${SCHEMA}`);
53
+
54
+ // The table in its PRE-migration shape — plaintext `token`, NOT NULL UNIQUE,
55
+ // indexed. Copied from migrations/003_pms.sql so the migration is exercised
56
+ // against what it will actually meet on a live instance.
57
+ await pool.query(`
58
+ CREATE TABLE builders (id bigserial PRIMARY KEY, github_id text, github_login text,
59
+ display_name text, avatar_url text, total_credits int DEFAULT 0, rank text DEFAULT 'archon',
60
+ status text DEFAULT 'active', system_role text, anthropic_user_id text);
61
+ CREATE TABLE builder_sessions (id bigserial PRIMARY KEY, builder_id bigint REFERENCES builders(id),
62
+ token text NOT NULL UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
63
+ last_used_at timestamptz DEFAULT now(), expires_at timestamptz, revoked_at timestamptz);
64
+ CREATE INDEX idx_builder_sessions_token ON builder_sessions (token);
65
+ INSERT INTO builders (github_login) VALUES ('probe');
66
+ `);
67
+
68
+ const client = await pool.connect();
69
+ await client.query(`SET search_path TO ${SCHEMA}`);
70
+ const poolPath = require.resolve('../src/bongos/pool.js');
71
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: client } };
72
+ const kernel = require('../src/bongos/db-kernel.js');
73
+
74
+ let passed = 0, failed = 0;
75
+ const t = async (name, fn) => {
76
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
77
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
78
+ };
79
+ const row = async (sql, p) => (await client.query(sql, p)).rows[0];
80
+
81
+ // Sessions that exist BEFORE the migration, written the old way. These stand in
82
+ // for every builder holding a live bearer on deploy day.
83
+ const LIVE_CLI = 'pre-existing-cli-bearer-2f8a';
84
+ const LIVE_WEB = 'pre-existing-web-bearer-91cd';
85
+ await client.query(
86
+ `INSERT INTO builder_sessions (builder_id, token, source, expires_at)
87
+ VALUES (1,$1,'cli',NULL), (1,$2,'web', now() + interval '30 days')`,
88
+ [LIVE_CLI, LIVE_WEB]
89
+ );
90
+
91
+ // ── the migration, for real ───────────────────────────────────────────────────
92
+
93
+ const migrationSql = readFileSync(MIGRATION, 'utf8');
94
+ await client.query(migrationSql);
95
+
96
+ await t('THE ONE THAT MATTERS: a bearer minted before the migration still authenticates after it', async () => {
97
+ // If this fails, the deploy signs out every builder holding a session — the
98
+ // failure the backfill exists to prevent, and the one nobody can undo from
99
+ // outside because fixing it needs a session.
100
+ const got = await kernel.lookupSession(LIVE_CLI);
101
+ assert.ok(got, 'the pre-existing CLI bearer no longer resolves');
102
+ assert.equal(got.github_login, 'probe');
103
+ });
104
+
105
+ await t('a pre-existing WEB session survives too, expiry intact', async () => {
106
+ const got = await kernel.lookupSession(LIVE_WEB);
107
+ assert.ok(got, 'the pre-existing web bearer no longer resolves');
108
+ assert.ok(got.expires_at instanceof Date, 'the web row lost its expiry');
109
+ });
110
+
111
+ await t('the SQL digest equals the Node digest — the two halves agree', async () => {
112
+ // The backfill computes encode(sha256(token::bytea),'hex') in Postgres; the
113
+ // kernel computes sha256 hex in Node. If these ever diverge, new sessions work
114
+ // and every migrated one is dead — a split nothing else here would localise.
115
+ const got = await row(`SELECT token_hash FROM builder_sessions WHERE source='cli'`);
116
+ assert.equal(got.token_hash, kernel.sessionTokenHash(LIVE_CLI));
117
+ });
118
+
119
+ // ── nothing replayable is left ────────────────────────────────────────────────
120
+
121
+ await t('the plaintext column is GONE, not merely emptied', async () => {
122
+ const got = await row(
123
+ `SELECT count(*)::int AS n FROM information_schema.columns
124
+ WHERE table_schema=$1 AND table_name='builder_sessions' AND column_name='token'`,
125
+ [SCHEMA]
126
+ );
127
+ assert.equal(got.n, 0, 'builder_sessions.token still exists');
128
+ });
129
+
130
+ await t('no stored value authenticates — a dump of the table is inert', async () => {
131
+ // The actual security claim, asserted the way an attacker would test it: take
132
+ // everything the table holds and try to log in with it. Reading token_hash and
133
+ // presenting it must NOT work — if lookupSession hashed nothing, or compared
134
+ // the presented value to itself, this is where that shows.
135
+ const { rows } = await client.query(`SELECT token_hash FROM builder_sessions`);
136
+ assert.ok(rows.length >= 2, 'fixture rows missing — the assertion would be vacuous');
137
+ for (const r of rows) {
138
+ assert.equal(await kernel.lookupSession(r.token_hash), null,
139
+ 'a value read straight out of the table authenticated');
140
+ }
141
+ });
142
+
143
+ await t('the uniqueness guarantee moved with the column', async () => {
144
+ await assert.rejects(
145
+ () => client.query(
146
+ `INSERT INTO builder_sessions (builder_id, token_hash, source) VALUES (1,$1,'cli')`,
147
+ [kernel.sessionTokenHash(LIVE_CLI)]
148
+ ),
149
+ /duplicate key|unique/i,
150
+ 'two sessions may not share a bearer'
151
+ );
152
+ });
153
+
154
+ // ── round-trip through the live code path ─────────────────────────────────────
155
+
156
+ await t('a session minted AFTER the migration resolves, and stores only its digest', async () => {
157
+ const fresh = 'freshly-minted-bearer-77b0';
158
+ await kernel.createSession({ builderId: '1', token: fresh, source: 'cli', userAgent: 'probe', expiresAt: null });
159
+ const got = await kernel.lookupSession(fresh);
160
+ assert.ok(got && got.github_login === 'probe', 'a new session did not resolve');
161
+ const stored = await row(`SELECT token_hash FROM builder_sessions WHERE token_hash = $1`,
162
+ [kernel.sessionTokenHash(fresh)]);
163
+ assert.ok(stored, 'the new row is not keyed on the digest');
164
+ });
165
+
166
+ await t('createSession hands the caller back the bearer it was given', async () => {
167
+ // The mint response has to carry the plaintext to the client — it is the only
168
+ // copy that will ever exist. It now comes from the ARGUMENT, since the row
169
+ // cannot return it.
170
+ const tok = 'returned-to-caller-a301';
171
+ const s = await kernel.createSession({ builderId: '1', token: tok, source: 'cli', userAgent: 'probe', expiresAt: null });
172
+ assert.equal(s.token, tok);
173
+ });
174
+
175
+ await t('revokeSession still kills a session it is handed by bearer', async () => {
176
+ const db = require('../src/bongos/db.js');
177
+ const tok = 'to-be-revoked-5e12';
178
+ await kernel.createSession({ builderId: '1', token: tok, source: 'cli', userAgent: 'probe', expiresAt: null });
179
+ assert.ok(await kernel.lookupSession(tok), 'precondition: the session should resolve first');
180
+ await db.revokeSession(tok);
181
+ assert.equal(await kernel.lookupSession(tok), null, 'revoke by bearer no longer reaches the row');
182
+ });
183
+
184
+ await t('the migration is idempotent — a second run is a no-op, not an error', async () => {
185
+ await client.query(migrationSql);
186
+ assert.ok(await kernel.lookupSession(LIVE_CLI), 'a re-run broke a live session');
187
+ });
188
+
189
+ client.release();
190
+ await pool.query(`DROP SCHEMA ${SCHEMA} CASCADE`);
191
+ await pool.end();
192
+ console.log(`\nsession_token_hash_db: ${passed} passed, ${failed} failed`);
193
+ process.exit(failed ? 1 : 0);