@bongos/core 1.19.791 → 1.19.793
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 +48 -33
- package/.claude/skills/builder-reauth/SKILL.md +9 -7
- package/README.md +1 -1
- package/bin/bongos.js +12 -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/api/openapi.json +1 -1
- package/docs/copy-inventory.md +31 -31
- package/docs/copy-registry.json +41 -41
- package/docs/module-api-changelog.md +4 -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/paste-token.js +4 -2
- package/scripts/gds/reauth.js +11 -9
- 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 +13 -4
- 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/tests/bongos_cli.mjs +28 -0
- package/tests/cli_token_reissue.mjs +29 -5
|
@@ -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);
|
package/tests/bongos_cli.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { strict as assert } from 'node:assert';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
15
|
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
16
17
|
import { fileURLToPath } from 'node:url';
|
|
17
18
|
import { dirname, join } from 'node:path';
|
|
18
19
|
|
|
@@ -43,6 +44,33 @@ test('verbHelpText: names the verb, its summary, and the full-help pointer', ()
|
|
|
43
44
|
assert.match(out, /bongos help/);
|
|
44
45
|
});
|
|
45
46
|
|
|
47
|
+
// task 1003961 — the hall's re-auth copy button emits `bongos paste-token <token>`.
|
|
48
|
+
// That string is composed by the SERVER and run on an INSTANCE, so the verb has to
|
|
49
|
+
// exist here or the one-click path dead-ends on "unknown command". Pinned against
|
|
50
|
+
// the routes file so the two halves cannot drift apart.
|
|
51
|
+
test('paste-token is a registered verb wired to paste-token.js', () => {
|
|
52
|
+
const def = cli.VERBS['paste-token'];
|
|
53
|
+
assert.ok(def, 'bongos paste-token must be a registered verb');
|
|
54
|
+
assert.equal(def.script, 'paste-token.js');
|
|
55
|
+
assert.equal(def.group, 'account', 'it belongs with the other session verbs');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('paste-token is bundled into the standalone binary, like reauth', () => {
|
|
59
|
+
// Two halves of one on-ramp: reauth mints the token, paste-token stores it.
|
|
60
|
+
// A builder recovering a dead session on a bare laptop has only the binary.
|
|
61
|
+
assert.ok(cli.BUNDLED_RUNNERS['paste-token.js'],
|
|
62
|
+
'paste-token.js must be in BUNDLED_RUNNERS or the compiled binary refuses the verb');
|
|
63
|
+
assert.ok(cli.BUNDLED_RUNNERS['reauth.js'], 'reauth is the sibling this mirrors');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('the server emits exactly the verb the CLI registers', () => {
|
|
67
|
+
const routes = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'bongos', 'routes', 'auth.js'), 'utf8');
|
|
68
|
+
const m = routes.match(/paste_command:\s*`([^`]+)`/);
|
|
69
|
+
assert.ok(m, 'auth.js should emit a paste_command template literal');
|
|
70
|
+
const verb = m[1].split(' ')[1];
|
|
71
|
+
assert.ok(cli.VERBS[verb], `server emits "bongos ${verb}" but the CLI has no such verb`);
|
|
72
|
+
});
|
|
73
|
+
|
|
46
74
|
test('VERBS: selfHelp is only claimed by scripts that really print their own usage', () => {
|
|
47
75
|
// Verified by reading each script (they all branch on --help/-h to a usage()
|
|
48
76
|
// printer). If a verb is added here, verify its script first — a wrong
|
|
@@ -119,16 +119,23 @@ const issueBlock = (() => {
|
|
|
119
119
|
return routeSrc.slice(start, start + 3000);
|
|
120
120
|
})();
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
// Refreshed for the current handler (task 1003961). These two assertions named
|
|
123
|
+
// `parseCookie(req, SESSION_COOKIE)` and `res.status(403)`, the shape BEFORE the
|
|
124
|
+
// handler moved to the `sessionCookieToken` helper and the `res.fail` envelope.
|
|
125
|
+
// They had been red on main ever since — invisible, because this file is in the
|
|
126
|
+
// runner's INTEGRATION set and self-skips in CI's DB-free lane. Left alone they
|
|
127
|
+
// would have masked the new assertions below: a file that is already red cannot
|
|
128
|
+
// tell you whether the test you just added passes.
|
|
129
|
+
t("the issue handler reads the session COOKIE (not just Authorization)", () => {
|
|
130
|
+
assert.ok(issueBlock.includes('sessionCookieToken(req)'),
|
|
131
|
+
'handler must resolve the browser cookie via sessionCookieToken(req)');
|
|
125
132
|
});
|
|
126
133
|
|
|
127
134
|
t('the issue handler rejects bearer-only callers with cookie_required', () => {
|
|
128
135
|
assert.ok(issueBlock.includes("cookie_required"),
|
|
129
136
|
"error code 'cookie_required' missing");
|
|
130
|
-
assert.ok(/status
|
|
131
|
-
'must respond
|
|
137
|
+
assert.ok(/status:\s*403/.test(issueBlock),
|
|
138
|
+
'must respond 403 when the cookie is missing (res.fail envelope carries `status: 403`)');
|
|
132
139
|
});
|
|
133
140
|
|
|
134
141
|
t("mints a session with source='cli' (CHECK constraint compliant)", () => {
|
|
@@ -141,6 +148,23 @@ t('returns a paste_command field the portal can show verbatim', () => {
|
|
|
141
148
|
'response should include paste_command for one-click UX');
|
|
142
149
|
});
|
|
143
150
|
|
|
151
|
+
// The regression this pins (task 1003961): paste_command is composed on the SERVER
|
|
152
|
+
// and run on an INSTANCE, where the core is an installed dependency and there is no
|
|
153
|
+
// `scripts/gds/` directory. Emitting a repo-relative script path handed every
|
|
154
|
+
// instance builder a command naming a file they did not have. A verb has no such
|
|
155
|
+
// coupling — `bongos` is on PATH wherever the core is installed.
|
|
156
|
+
t('paste_command names a CLI VERB, never a filesystem path', () => {
|
|
157
|
+
const m = issueBlock.match(/paste_command:\s*`([^`]+)`/);
|
|
158
|
+
assert.ok(m, 'paste_command should be a template literal the test can read');
|
|
159
|
+
const cmd = m[1];
|
|
160
|
+
assert.ok(/^bongos paste-token /.test(cmd),
|
|
161
|
+
`paste_command must start with the verb form, got: ${cmd}`);
|
|
162
|
+
assert.ok(!cmd.includes('/'),
|
|
163
|
+
`paste_command must contain no path separator — it runs where the layout differs. Got: ${cmd}`);
|
|
164
|
+
assert.ok(!/\bnode\b/.test(cmd),
|
|
165
|
+
`paste_command must not invoke node directly (that assumes a script on disk). Got: ${cmd}`);
|
|
166
|
+
});
|
|
167
|
+
|
|
144
168
|
// ---------------------------------------------------------------------------
|
|
145
169
|
|
|
146
170
|
console.log(`\n${passed} passed, ${failed} failed`);
|