@bongos/core 1.19.887 → 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.
@@ -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);