@bongos/core 1.19.791 → 1.19.792
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 +43 -28
- package/.claude/skills/builder-reauth/SKILL.md +5 -3
- package/README.md +1 -1
- package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +2 -0
- package/docs/adr/0294-cli-sessions-do-not-expire.md +56 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +31 -31
- package/docs/copy-registry.json +41 -41
- package/docs/module-api-changelog.md +2 -0
- package/migrations/core_243_cli_sessions_never_expire.sql +58 -0
- package/modules/hall-ui/public/settings.js +5 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-auth-check.js +1 -1
- package/scripts/gds/cli-lib.js +2 -2
- package/scripts/gds/reauth.js +9 -7
- package/scripts/gds/run-unit-tests.js +7 -0
- package/src/bongos/auth-github.js +2 -0
- package/src/bongos/db-kernel.js +29 -11
- package/src/bongos/db.js +33 -24
- package/src/bongos/routes/auth.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/auth_session_expiry_db.mjs +183 -0
- package/tests/auth_session_ttl.mjs +124 -11
package/src/bongos/db.js
CHANGED
|
@@ -664,32 +664,41 @@ async function reactivateBuilder({ builderId, actorBuilderId }) {
|
|
|
664
664
|
// Sessions (auth tokens)
|
|
665
665
|
// -------------------------------------------------------------------------
|
|
666
666
|
//
|
|
667
|
-
// V3.R38 / task #259 — short-lived sessions with refresh-on-use.
|
|
667
|
+
// V3.R38 / task #259 — short-lived sessions with refresh-on-use (1h).
|
|
668
668
|
// V3.R90 / task #400 (2026-05-29) — TTL widened from 1h to 24h.
|
|
669
|
+
// ADR 0294 / task 1003956 (2026-09-16) — CLI sessions lose their expiry entirely.
|
|
669
670
|
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
// *
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
//
|
|
692
|
-
//
|
|
671
|
+
// The arc is one long argument the idle timer kept losing. 1h kicked builders
|
|
672
|
+
// out over lunch; 24h kicked them out over a weekend. Each round shortened the
|
|
673
|
+
// nuisance without touching the threat, because the timer never bounded a leaked
|
|
674
|
+
// token in the first place — refresh-on-use means the thief's own traffic keeps
|
|
675
|
+
// it alive, and only the legitimate holder, who steps away, is ever signed out.
|
|
676
|
+
// So the expiry is gone from CLI sessions rather than widened a third time.
|
|
677
|
+
//
|
|
678
|
+
// * A NEW session whose caller names no expiry gets NONE — expires_at NULL,
|
|
679
|
+
// which lookupSession treats as never-expires. Every such caller is
|
|
680
|
+
// source='cli'. The WEB flow passes an explicit 30 days and is unchanged.
|
|
681
|
+
// * Every successful lookupSession bumps last_used_at, and rolls expires_at
|
|
682
|
+
// forward to NOW()+24h ONLY for a row that already has one. A NULL stays
|
|
683
|
+
// NULL — otherwise the first request after sign-in would mint the very
|
|
684
|
+
// timeout this removed. The bump is fire-and-forget: an optimization for the
|
|
685
|
+
// next request, not a precondition for THIS one, so it isn't awaited.
|
|
686
|
+
// * Migration core_243 cleared expires_at on live source='cli' rows, so the
|
|
687
|
+
// change reached sessions that already existed instead of making every
|
|
688
|
+
// builder re-auth one last time. (Migration 035 had backfilled a 24h grace
|
|
689
|
+
// in the other direction, for the same reason: don't sign out the session
|
|
690
|
+
// deploying the migration.)
|
|
691
|
+
// * Nothing here is load-bearing for C7 (rank-change-near-instant):
|
|
692
|
+
// setBuilderRank calls revokeAllSessionsForBuilder on every demotion (see
|
|
693
|
+
// below) and requireBuilder denies a deactivated builder on the hot path, so
|
|
694
|
+
// authority drift is closed by revocation, not by waiting out a clock.
|
|
695
|
+
// * The remedy for a leaked bearer is the own-scoped revoke of ADR 0206 —
|
|
696
|
+
// GET/POST /me/sessions, and "Sign out everywhere" in the hall — which kills
|
|
697
|
+
// it now rather than within a day. That is why the timer is affordable to
|
|
698
|
+
// lose: the thing it was nominally for has a faster answer that already ships.
|
|
699
|
+
|
|
700
|
+
// The roll-forward window for a session that HAS an expiry, in milliseconds.
|
|
701
|
+
// Centralized so the test suite and the code agree on one number.
|
|
693
702
|
|
|
694
703
|
// SESSION_TTL_MS — carved to ./db-kernel (ADR 0102); imported + re-exported above.
|
|
695
704
|
|
|
@@ -1049,7 +1049,8 @@ module.exports = function buildAuthRouter() {
|
|
|
1049
1049
|
// audit_log distinguishes portal-reissue from Device Flow tokens.
|
|
1050
1050
|
source: 'cli',
|
|
1051
1051
|
userAgent: req.headers['user-agent'] ?? null,
|
|
1052
|
-
// expiresAt: null →
|
|
1052
|
+
// expiresAt: null → NO expiry: a CLI bearer lasts until revoked (ADR 0294).
|
|
1053
|
+
// The builder kills it from this same Settings page, under Active sessions.
|
|
1053
1054
|
expiresAt: null,
|
|
1054
1055
|
});
|
|
1055
1056
|
res.json({
|
|
@@ -1189,7 +1190,7 @@ module.exports = function buildAuthRouter() {
|
|
|
1189
1190
|
token,
|
|
1190
1191
|
source: 'cli', // 003_pms.sql CHECK; the app is a CLI-class bearer client
|
|
1191
1192
|
userAgent: `devbox-app pair (${req.headers['user-agent'] ?? 'unknown'})`,
|
|
1192
|
-
expiresAt: null, //
|
|
1193
|
+
expiresAt: null, // no expiry — a CLI bearer lasts until revoked (ADR 0294)
|
|
1193
1194
|
});
|
|
1194
1195
|
const out = pairStore.approve((req.body || {}).pair_code, {
|
|
1195
1196
|
token,
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.792'; // 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');
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// tests/auth_session_expiry_db.mjs — ADR 0294, task 1003956 (real DB).
|
|
2
|
+
//
|
|
3
|
+
// The DB-free sibling (auth_session_ttl.mjs) pins the CONSTANT, createSession's
|
|
4
|
+
// choice of value against a stub pool, and the TEXT of the refresh statement and
|
|
5
|
+
// the migration. That last part is the weak half, and the grader on this task was
|
|
6
|
+
// right to say so — though not for the reason first assumed. Most edits to the
|
|
7
|
+
// guard's WORDING do break its regex, so the sibling is tighter than it looks.
|
|
8
|
+
// What a regex cannot see is whether Postgres ACCEPTS the statement at all.
|
|
9
|
+
//
|
|
10
|
+
// Measured, by deleting the CASE's `END` and running both files: the sibling
|
|
11
|
+
// reported 20 passed, 0 failed on SQL that fails to parse — SQL that would throw
|
|
12
|
+
// on every authenticated request in production. This file caught it.
|
|
13
|
+
//
|
|
14
|
+
// Worth knowing WHICH assertions caught it, because it is not the obvious one.
|
|
15
|
+
// The refresh is fire-and-forget with a `.catch()`, so a broken UPDATE fails
|
|
16
|
+
// SILENTLY: expires_at stays NULL and the never-expires assertion still passes.
|
|
17
|
+
// What failed was `last_used_at` never moving and the web row never rolling. Those
|
|
18
|
+
// two are not padding — on this statement they are the only live smoke alarm.
|
|
19
|
+
//
|
|
20
|
+
// So this file proves what only a real transaction engine can:
|
|
21
|
+
//
|
|
22
|
+
// 1. A CLI session is CREATED with no expiry, and a web session's explicit
|
|
23
|
+
// 30 days survives untouched — the two halves of the default change.
|
|
24
|
+
// 2. THE REGRESSION GUARD EXECUTES — after a real lookupSession, a CLI row's
|
|
25
|
+
// expires_at is still NULL. Without the CASE this is where the timeout
|
|
26
|
+
// comes back, on the first request after sign-in.
|
|
27
|
+
// 3. A web row's expiry STILL rolls forward, so the guard narrowed the write
|
|
28
|
+
// to NULL rows rather than disabling refresh-on-use for everyone.
|
|
29
|
+
// 4. Expiry is still HONOURED where present, and revocation still wins — the
|
|
30
|
+
// two things a "sessions never expire" change could plausibly have broken.
|
|
31
|
+
// 5. THE MIGRATION, run for real: it clears a pre-existing CLI expiry, leaves
|
|
32
|
+
// web and revoked rows alone, and is idempotent.
|
|
33
|
+
// 6. The point of the whole task — a session idle far past 24h authenticates.
|
|
34
|
+
//
|
|
35
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in
|
|
36
|
+
// run-unit-tests.js's INTEGRATION set so the default DB-free lane never runs it.
|
|
37
|
+
//
|
|
38
|
+
// Run: DATABASE_URL=... node tests/auth_session_expiry_db.mjs
|
|
39
|
+
|
|
40
|
+
import { createRequire } from 'node:module';
|
|
41
|
+
import { readFileSync } from 'node:fs';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
import { dirname, join } from 'node:path';
|
|
44
|
+
import { strict as assert } from 'node:assert';
|
|
45
|
+
|
|
46
|
+
const require = createRequire(import.meta.url);
|
|
47
|
+
const { Pool } = require('pg');
|
|
48
|
+
|
|
49
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const MIGRATION = join(here, '..', 'migrations', 'core_243_cli_sessions_never_expire.sql');
|
|
51
|
+
|
|
52
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
53
|
+
try {
|
|
54
|
+
await pool.query('SELECT 1');
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.log(`auth_session_expiry_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
57
|
+
await pool.end().catch(() => {});
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// An isolated schema, so this never touches a real builders/builder_sessions.
|
|
62
|
+
const SCHEMA = `ttl_probe_${process.pid}`;
|
|
63
|
+
await pool.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
64
|
+
await pool.query(`SET search_path TO ${SCHEMA}`);
|
|
65
|
+
await pool.query(`
|
|
66
|
+
CREATE TABLE builders (id bigserial PRIMARY KEY, github_id text, github_login text,
|
|
67
|
+
display_name text, avatar_url text, total_credits int DEFAULT 0, rank text DEFAULT 'archon',
|
|
68
|
+
status text DEFAULT 'active', system_role text, anthropic_user_id text);
|
|
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(),
|
|
71
|
+
last_used_at timestamptz DEFAULT now(), expires_at timestamptz, revoked_at timestamptz);
|
|
72
|
+
INSERT INTO builders (github_login) VALUES ('probe');
|
|
73
|
+
`);
|
|
74
|
+
|
|
75
|
+
// Run the REAL kernel against this schema: stub ./pool in the require cache so
|
|
76
|
+
// db-kernel binds our client, exactly as the production code binds the singleton.
|
|
77
|
+
const client = await pool.connect();
|
|
78
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
79
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
80
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: client } };
|
|
81
|
+
const kernel = require('../src/bongos/db-kernel.js');
|
|
82
|
+
|
|
83
|
+
let passed = 0, failed = 0;
|
|
84
|
+
const t = async (name, fn) => {
|
|
85
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
86
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
87
|
+
};
|
|
88
|
+
const row = async (sql, p) => (await client.query(sql, p)).rows[0];
|
|
89
|
+
// refresh-on-use is fire-and-forget: let the UPDATE land before reading it back.
|
|
90
|
+
const settle = () => new Promise((r) => setTimeout(r, 300));
|
|
91
|
+
|
|
92
|
+
const thirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
93
|
+
|
|
94
|
+
await t('a CLI session is created with NO expiry', async () => {
|
|
95
|
+
const s = await kernel.createSession({ builderId: '1', token: 'cli-tok', source: 'cli', userAgent: 'probe', expiresAt: null });
|
|
96
|
+
assert.equal(s.expires_at, null);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await t("a web session's explicit 30-day expiry is stored as given", async () => {
|
|
100
|
+
const s = await kernel.createSession({ builderId: '1', token: 'web-tok', source: 'web', userAgent: 'probe', expiresAt: thirtyDays });
|
|
101
|
+
assert.equal(s.expires_at.getTime(), thirtyDays.getTime());
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('lookupSession resolves a CLI bearer to its builder', async () => {
|
|
105
|
+
const got = await kernel.lookupSession('cli-tok');
|
|
106
|
+
assert.equal(got && got.github_login, 'probe');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// THE GUARD. This is the assertion the source-matching sibling cannot make.
|
|
110
|
+
await t('after a real use, a CLI session STILL has no expiry (the CASE guard)', async () => {
|
|
111
|
+
await kernel.lookupSession('cli-tok');
|
|
112
|
+
await settle();
|
|
113
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='cli-tok'`)).expires_at, null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
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'`);
|
|
119
|
+
await kernel.lookupSession('cli-tok');
|
|
120
|
+
await settle();
|
|
121
|
+
const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
|
|
122
|
+
assert.ok(after > new Date(Date.now() - 60_000), `last_used_at did not move: ${after} (was ${before})`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await t('a web session still rolls its expiry forward (refresh-on-use intact)', async () => {
|
|
126
|
+
await kernel.lookupSession('web-tok');
|
|
127
|
+
await settle();
|
|
128
|
+
const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token='web-tok'`)).expires_at;
|
|
129
|
+
assert.ok(got !== null && got < thirtyDays, `expected a rolled-forward date below 30d, got ${got}`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
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')`);
|
|
135
|
+
assert.equal(await kernel.lookupSession('dead-tok'), null);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
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())`);
|
|
141
|
+
assert.equal(await kernel.lookupSession('revoked-tok'), null);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The migration, against rows shaped like the ones live instances actually hold.
|
|
145
|
+
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(migrationSql);
|
|
152
|
+
|
|
153
|
+
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);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
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);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
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);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await t('core_243 is idempotent — a re-run changes nothing', async () => {
|
|
166
|
+
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);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// The whole point of the task, stated as a test.
|
|
172
|
+
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'`);
|
|
174
|
+
assert.notEqual(await kernel.lookupSession('cli-tok'), null);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await client.query(`SET search_path TO public`);
|
|
178
|
+
client.release();
|
|
179
|
+
await pool.query(`DROP SCHEMA ${SCHEMA} CASCADE`);
|
|
180
|
+
await pool.end();
|
|
181
|
+
|
|
182
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
183
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -19,15 +19,30 @@
|
|
|
19
19
|
|
|
20
20
|
import { strict as assert } from 'node:assert';
|
|
21
21
|
import { createRequire } from 'node:module';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
22
23
|
|
|
23
24
|
const require = createRequire(import.meta.url);
|
|
24
25
|
const db = require('../src/bongos/db.js');
|
|
25
26
|
|
|
26
27
|
let passed = 0;
|
|
27
28
|
let failed = 0;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Cases are queued rather than run inline so an ASYNC one (the createSession
|
|
30
|
+
// checks below hit a stubbed pool through an async function) is actually awaited.
|
|
31
|
+
// Running those inline would report PASS before the assertion resolved, and a
|
|
32
|
+
// rejection would surface as an unhandled rejection instead of a FAIL.
|
|
33
|
+
const queue = [];
|
|
34
|
+
function t(name, fn) { queue.push({ name, fn }); }
|
|
35
|
+
// Section headers queue too, so they still print ABOVE the cases they introduce
|
|
36
|
+
// instead of all bunching at the top once execution is deferred.
|
|
37
|
+
function section(label) { queue.push({ label }); }
|
|
38
|
+
async function runQueue() {
|
|
39
|
+
for (const item of queue) {
|
|
40
|
+
if (item.label) { console.log(item.label); continue; }
|
|
41
|
+
const { name, fn } = item;
|
|
42
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
43
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
queue.length = 0;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
// ---------------------------------------------------------------------------
|
|
@@ -103,7 +118,7 @@ function isDemotion(oldRank, newRank) {
|
|
|
103
118
|
return newIdx > oldIdx;
|
|
104
119
|
}
|
|
105
120
|
|
|
106
|
-
|
|
121
|
+
section('\ndemotion-detection (revoke-all hook in setBuilderRank):');
|
|
107
122
|
|
|
108
123
|
t('archon → metic is a demotion (revoke triggers)', () => {
|
|
109
124
|
assert.equal(isDemotion('archon', 'metic'), true);
|
|
@@ -135,21 +150,119 @@ t('an unknown rank string does not crash and returns false', () => {
|
|
|
135
150
|
});
|
|
136
151
|
|
|
137
152
|
// ---------------------------------------------------------------------------
|
|
138
|
-
// 3.
|
|
153
|
+
// 3. No expiry on a CLI session (ADR 0294)
|
|
139
154
|
// ---------------------------------------------------------------------------
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
155
|
+
// The TTL was argued three times — 1h (V3.R38/#259), 24h (V3.R90/#400), then
|
|
156
|
+
// gone (ADR 0294 / task 1003956). The first two rounds moved the number; the
|
|
157
|
+
// third removed the mechanism, because refresh-on-use meant the timer only ever
|
|
158
|
+
// fired on a session gone QUIET — the legitimate holder stepping away — while a
|
|
159
|
+
// leaked token's own traffic kept it alive. What is asserted here is the shape
|
|
160
|
+
// that replaced it, and the two ways it could silently come back.
|
|
161
|
+
//
|
|
162
|
+
// createSession is exercised against a STUB POOL rather than a live Postgres, in
|
|
163
|
+
// keeping with this file's no-real-PG stance: the claim under test is which
|
|
164
|
+
// value reaches the INSERT, which is a pure function of the argument. The pool
|
|
165
|
+
// module is stubbed in the require cache before db-kernel is loaded fresh, since
|
|
166
|
+
// db-kernel binds `pool` once at module load.
|
|
145
167
|
|
|
146
|
-
|
|
168
|
+
section('\nSESSION_TTL_MS constant:');
|
|
147
169
|
|
|
148
170
|
t('SESSION_TTL_MS is exactly 24 hours (86,400,000 ms)', () => {
|
|
149
171
|
assert.equal(db.SESSION_TTL_MS, 24 * 60 * 60 * 1000);
|
|
150
172
|
});
|
|
151
173
|
|
|
174
|
+
section('\ncreateSession expiry default (ADR 0294):');
|
|
175
|
+
|
|
176
|
+
// Load db-kernel over a stub pool that records the parameters it is handed.
|
|
177
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
178
|
+
const kernelPath = require.resolve('../src/bongos/db-kernel.js');
|
|
179
|
+
const realPool = require.cache[poolPath];
|
|
180
|
+
const realKernel = require.cache[kernelPath];
|
|
181
|
+
const calls = [];
|
|
182
|
+
require.cache[poolPath] = {
|
|
183
|
+
id: poolPath, filename: poolPath, loaded: true, exports: {
|
|
184
|
+
pool: { query: async (text, params) => { calls.push({ text, params }); return { rows: [{ id: '1' }] }; } },
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
delete require.cache[kernelPath];
|
|
188
|
+
const kernel = require('../src/bongos/db-kernel.js');
|
|
189
|
+
|
|
190
|
+
// expires_at is the 5th INSERT parameter — see createSession's VALUES list.
|
|
191
|
+
async function expiryFor(expiresAt) {
|
|
192
|
+
calls.length = 0;
|
|
193
|
+
await kernel.createSession({ builderId: '1', token: 't', source: 'cli', userAgent: null, expiresAt });
|
|
194
|
+
return calls[0].params[4];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
t('a CLI session named with NO expiry is stored with expires_at NULL', async () => {
|
|
198
|
+
assert.equal(await expiryFor(null), null);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
t('an OMITTED expiresAt is also stored as NULL, not a 24h default', async () => {
|
|
202
|
+
assert.equal(await expiryFor(undefined), null);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
t('an EXPLICIT expiry is preserved verbatim — the web flow keeps its 30 days', async () => {
|
|
206
|
+
const thirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
207
|
+
assert.equal((await expiryFor(thirtyDays)).getTime(), thirtyDays.getTime());
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
section('\nrefresh-on-use must not re-mint an expiry (ADR 0294):');
|
|
211
|
+
|
|
212
|
+
// THE REGRESSION THIS GUARDS. lookupSession rolls expires_at forward on every
|
|
213
|
+
// authenticated request. Without the NULL guard, the FIRST request after sign-in
|
|
214
|
+
// would write now()+24h onto a never-expires row and restore the whole timeout —
|
|
215
|
+
// silently, with createSession still looking correct. Asserted over the source
|
|
216
|
+
// because the behaviour lives in SQL the stub pool never executes.
|
|
217
|
+
const kernelSrc = readFileSync(new URL('../src/bongos/db-kernel.js', import.meta.url), 'utf8');
|
|
218
|
+
const refreshSql = kernelSrc.slice(
|
|
219
|
+
kernelSrc.indexOf('UPDATE builder_sessions'),
|
|
220
|
+
kernelSrc.indexOf('[row.session_id, SESSION_TTL_MS]')
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
t("refresh-on-use leaves a NULL expires_at NULL", () => {
|
|
224
|
+
assert.ok(refreshSql.includes('UPDATE builder_sessions'), 'refresh statement not found');
|
|
225
|
+
assert.match(refreshSql, /CASE\s*\n?\s*WHEN expires_at IS NULL THEN NULL/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
t('refresh-on-use still bumps last_used_at (the own-sessions list reads it)', () => {
|
|
229
|
+
assert.match(refreshSql, /last_used_at = now\(\)/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
section('\nmigration core_243 scope:');
|
|
233
|
+
|
|
234
|
+
// The migration clears expires_at on rows that already exist. Its source filter
|
|
235
|
+
// is the entire safety property: lookupSession only rolls an expiry it can
|
|
236
|
+
// already SEE, so a web row cleared here would stay cleared forever — an
|
|
237
|
+
// immortality nobody decided on, and unrecoverable by normal use.
|
|
238
|
+
//
|
|
239
|
+
// Comment lines are STRIPPED before matching. This file's header explains the
|
|
240
|
+
// source filter at length, so asserting over the raw text would let the prose
|
|
241
|
+
// satisfy a check the statement no longer meets — verified by deleting the WHERE
|
|
242
|
+
// clause and watching the revoked_at assertion still pass.
|
|
243
|
+
const migrationSql = readFileSync(
|
|
244
|
+
new URL('../migrations/core_243_cli_sessions_never_expire.sql', import.meta.url), 'utf8'
|
|
245
|
+
)
|
|
246
|
+
.split('\n')
|
|
247
|
+
.filter((line) => !line.trim().startsWith('--'))
|
|
248
|
+
.join('\n');
|
|
249
|
+
|
|
250
|
+
t("core_243 only clears CLI rows", () => {
|
|
251
|
+
assert.match(migrationSql, /SET expires_at = NULL/);
|
|
252
|
+
assert.match(migrationSql, /WHERE source = 'cli'/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
t('core_243 leaves revoked rows alone (they are the audit trail)', () => {
|
|
256
|
+
assert.match(migrationSql, /revoked_at IS NULL/);
|
|
257
|
+
});
|
|
258
|
+
|
|
152
259
|
// ---------------------------------------------------------------------------
|
|
153
260
|
|
|
261
|
+
await runQueue();
|
|
262
|
+
|
|
263
|
+
// Put the real modules back so anything importing this file after it is unaffected.
|
|
264
|
+
if (realPool) require.cache[poolPath] = realPool; else delete require.cache[poolPath];
|
|
265
|
+
if (realKernel) require.cache[kernelPath] = realKernel; else delete require.cache[kernelPath];
|
|
266
|
+
|
|
154
267
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
155
268
|
process.exit(failed === 0 ? 0 : 1);
|