@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.
- package/.bongos-core.json +46 -21
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/gds-db-backup.md +30 -2
- package/migrations/core_249_session_token_hash.sql +103 -0
- package/modules/agents/lib/validate.js +49 -2
- package/modules/agents/migrations/agents_005_cron_trigger.sql +78 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/db-backup-offsite.sh +105 -9
- package/scripts/gds/run-unit-tests.js +8 -0
- package/src/bongos/db-kernel.js +42 -6
- package/src/bongos/db.js +9 -2
- package/src/module-api.js +1 -1
- package/tests/agents_cron_trigger.mjs +133 -0
- package/tests/agents_validate.mjs +13 -5
- package/tests/auth_session_expiry_db.mjs +26 -21
- package/tests/government_live_proof.mjs +8 -2
- package/tests/own_session_revoke.mjs +13 -1
- package/tests/session_token_hash.mjs +139 -0
- package/tests/session_token_hash_db.mjs +193 -0
|
@@ -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
|
-
|
|
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);
|