@bongos/core 1.19.417 → 1.19.419

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.
Files changed (36) hide show
  1. package/.bongos-core.json +87 -32
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +22 -0
  4. package/clients/bongos-client/index.cjs +22 -0
  5. package/clients/bongos-client/index.d.ts +33 -0
  6. package/clients/bongos-client/index.mjs +22 -0
  7. package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +59 -0
  8. package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +599 -3
  11. package/docs/api-reference.md +17 -2
  12. package/docs/architecture.md +1 -0
  13. package/docs/copy-inventory.md +51 -36
  14. package/docs/copy-registry.json +175 -40
  15. package/docs/file-map.md +1 -0
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +22 -0
  18. package/modules/hall-ui/public/settings-sessions.js +119 -0
  19. package/modules/hall-ui/public/settings.css +25 -0
  20. package/modules/hall-ui/public/settings.html +22 -0
  21. package/modules/platform-identity/account-visibility.js +58 -0
  22. package/modules/platform-identity/connections.js +490 -0
  23. package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
  24. package/modules/platform-identity/module.json +1 -1
  25. package/modules/platform-identity/routes/connections.js +236 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/src/bongos/db.js +46 -0
  29. package/src/bongos/route-rank-check.js +10 -0
  30. package/src/bongos/routes/my-sessions.js +128 -0
  31. package/src/bongos/routes.js +5 -0
  32. package/src/module-api.js +1 -1
  33. package/tests/connections_api.mjs +568 -0
  34. package/tests/connections_ownership.mjs +313 -0
  35. package/tests/own_scope_guard.mjs +10 -0
  36. package/tests/own_session_revoke.mjs +410 -0
@@ -0,0 +1,410 @@
1
+ // tests/own_session_revoke.mjs — the own-scoped session self-service
2
+ // (task 1003341, goal 1000045; src/bongos/routes/my-sessions.js).
3
+ //
4
+ // The gap these routes close: a CLI bearer refreshes to now()+24h on every call,
5
+ // so a leaked one never expires, and the only revoke endpoints were gated on the
6
+ // admin `auth.session.revoke` atom — the victim needed an Archon. What this file
7
+ // pins is therefore the ISOLATION, not the plumbing:
8
+ //
9
+ // 1. a non-admin builder (xenos, the bottom rung) CAN list and revoke their
10
+ // own sessions, and sign out everywhere, with no rank and no permission;
11
+ // 2. that same builder CANNOT see or kill anybody else's — not by naming a
12
+ // builder in the query string, not by naming one in the body, and not by
13
+ // guessing a stranger's session id;
14
+ // 3. a revoked bearer stops authenticating on the very next request;
15
+ // 4. the token value never appears in a response.
16
+ //
17
+ // DB-FREE, and deliberately NOT a mock of the route's own answers: the fake pool
18
+ // below is a small in-memory `builder_sessions` table plus a mini-interpreter
19
+ // that builds each statement's filter FROM THAT STATEMENT'S OWN WHERE CLAUSE. So
20
+ // if `builder_id = $2` were ever dropped from the own-scoped revoke, the filter
21
+ // would genuinely widen and test 2 would genuinely fail — the isolation is
22
+ // executed, not asserted against a string. The interpreter refuses any statement
23
+ // whose placeholders it did not all consume, so a rewritten predicate it cannot
24
+ // read is a loud error rather than a silently weaker filter.
25
+ //
26
+ // Everything above the pool is REAL: the real auth.requireBuilder, the real
27
+ // db-kernel lookupSession (refresh-on-use and all), the real router, over real
28
+ // HTTP. Nothing about identity is stubbed, which is what makes (3) a proof.
29
+ //
30
+ // Run: node tests/own_session_revoke.mjs
31
+
32
+ import { strict as assert } from 'node:assert';
33
+ import { createRequire } from 'node:module';
34
+ import http from 'node:http';
35
+ import { readFileSync } from 'node:fs';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { dirname, join } from 'node:path';
38
+
39
+ const require = createRequire(import.meta.url);
40
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
41
+
42
+ let passed = 0;
43
+ let failed = 0;
44
+ async function t(name, fn) {
45
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
46
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
47
+ }
48
+
49
+ // ===========================================================================
50
+ // The in-memory builder_sessions table + its statement interpreter
51
+ // ===========================================================================
52
+
53
+ const DAY = 24 * 60 * 60 * 1000;
54
+ const BUILDERS = {
55
+ 1: { github_id: 1001, github_login: 'ada', display_name: 'Ada', avatar_url: null,
56
+ total_credits: 0, rank: 'xenos', status: 'active', system_role: null, anthropic_user_id: null },
57
+ 2: { github_id: 1002, github_login: 'bo', display_name: 'Bo', avatar_url: null,
58
+ total_credits: 0, rank: 'xenos', status: 'active', system_role: null, anthropic_user_id: null },
59
+ };
60
+
61
+ let sessions = [];
62
+ function seed() {
63
+ const now = Date.now();
64
+ sessions = [
65
+ // Ada's — a CLI bearer (the leaked-token case), a browser cookie, one already
66
+ // revoked and one already expired (neither may ever surface in a list).
67
+ { id: 101, builder_id: 1, token: 'adacli', source: 'cli', user_agent: 'cloud-bongos-cli',
68
+ created_at: new Date(now - 30 * DAY), last_used_at: new Date(now - 60_000), expires_at: new Date(now + DAY), revoked_at: null },
69
+ { id: 102, builder_id: 1, token: 'adaweb', source: 'web', user_agent: 'Mozilla/5.0',
70
+ created_at: new Date(now - 2 * DAY), last_used_at: new Date(now - 3_600_000), expires_at: new Date(now + DAY), revoked_at: null },
71
+ { id: 103, builder_id: 1, token: 'adaold', source: 'cli', user_agent: 'old laptop',
72
+ created_at: new Date(now - 90 * DAY), last_used_at: new Date(now - 80 * DAY), expires_at: new Date(now + DAY), revoked_at: new Date(now - 80 * DAY) },
73
+ { id: 104, builder_id: 1, token: 'adagone', source: 'cli', user_agent: 'lapsed',
74
+ created_at: new Date(now - 90 * DAY), last_used_at: new Date(now - 70 * DAY), expires_at: new Date(now - 60 * DAY), revoked_at: null },
75
+ // Bo's — the stranger's session Ada must never reach.
76
+ { id: 201, builder_id: 2, token: 'bocli', source: 'cli', user_agent: 'bo-cli',
77
+ created_at: new Date(now - DAY), last_used_at: new Date(now - 120_000), expires_at: new Date(now + DAY), revoked_at: null },
78
+ ];
79
+ }
80
+
81
+ // Build a row filter from the statement's OWN where clause. Every placeholder the
82
+ // clause mentions must be consumed by a fragment below; one that is not throws,
83
+ // so a predicate this cannot read can never degrade into "no constraint".
84
+ function predicateFor(sql, params) {
85
+ const where = sql.includes(' WHERE ') ? sql.slice(sql.indexOf(' WHERE ') + 7) : '';
86
+ const tests = [];
87
+ const consumed = new Set();
88
+ const take = (re, make) => {
89
+ const m = re.exec(where);
90
+ if (!m) return;
91
+ consumed.add(m[1]);
92
+ tests.push(make(params[Number(m[1]) - 1]));
93
+ };
94
+ take(/\b(?:s\.)?token = \$(\d+)/, (v) => (r) => r.token === v);
95
+ take(/(?<!_)\b(?:s\.)?id = \$(\d+)/, (v) => (r) => String(r.id) === String(v));
96
+ take(/\bbuilder_id = \$(\d+)/, (v) => (r) => String(r.builder_id) === String(v));
97
+ if (/revoked_at IS NULL/.test(where)) tests.push((r) => r.revoked_at === null);
98
+ if (/expires_at IS NULL OR expires_at > now\(\)/.test(where)) {
99
+ tests.push((r) => r.expires_at === null || r.expires_at > new Date());
100
+ }
101
+ for (const m of where.matchAll(/\$(\d+)/g)) {
102
+ if (!consumed.has(m[1])) {
103
+ throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}" — the interpreter must not silently ignore a filter`);
104
+ }
105
+ }
106
+ return (r) => tests.every((fn) => fn(r));
107
+ }
108
+
109
+ // The columns a SELECT actually names, so a projection that started shipping
110
+ // `token` would put it in the response body (and trip the leak test) rather than
111
+ // be quietly dropped by a hand-written fake.
112
+ function selectedColumns(sql) {
113
+ const m = /^SELECT (.+?) FROM /.exec(sql);
114
+ return m[1].split(',').map((c) => c.trim());
115
+ }
116
+
117
+ let calls = [];
118
+ const pool = {
119
+ query: async (text, params = []) => {
120
+ const sql = String(text).replace(/\s+/g, ' ').trim();
121
+ calls.push({ sql, params });
122
+ const match = predicateFor(sql, params);
123
+ if (/^SELECT/.test(sql) && /AS session_id/.test(sql)) {
124
+ // db-kernel lookupSession: the token→session join. It applies the revoked
125
+ // and expiry checks itself in JS, so the SQL filters on token alone.
126
+ const s = sessions.find(match);
127
+ if (!s) return { rows: [], rowCount: 0 };
128
+ return { rows: [{ session_id: s.id, builder_id: s.builder_id, expires_at: s.expires_at,
129
+ revoked_at: s.revoked_at, source: s.source, ...BUILDERS[s.builder_id] }], rowCount: 1 };
130
+ }
131
+ if (/^SELECT/.test(sql)) {
132
+ const cols = selectedColumns(sql);
133
+ const rows = sessions.filter(match)
134
+ .sort((a, b) => b.last_used_at - a.last_used_at || b.id - a.id)
135
+ .map((r) => Object.fromEntries(cols.map((c) => [c, r[c]])));
136
+ return { rows, rowCount: rows.length };
137
+ }
138
+ if (/^UPDATE builder_sessions SET revoked_at/.test(sql)) {
139
+ const hit = sessions.filter(match);
140
+ for (const r of hit) r.revoked_at = new Date();
141
+ return { rows: [], rowCount: hit.length };
142
+ }
143
+ if (/^UPDATE builder_sessions SET last_used_at/.test(sql)) {
144
+ const hit = sessions.filter(match);
145
+ for (const r of hit) { r.last_used_at = new Date(); r.expires_at = new Date(Date.now() + DAY); }
146
+ return { rows: [], rowCount: hit.length };
147
+ }
148
+ throw new Error(`fake pool: unhandled statement — ${sql.slice(0, 90)}`);
149
+ },
150
+ on: () => {},
151
+ };
152
+
153
+ // Pin the fake pool BEFORE anything that reaches for a connection is loaded, so
154
+ // db, db-kernel and auth all resolve to it and no real pg Pool is ever built.
155
+ const poolPath = require.resolve('../src/bongos/pool.js');
156
+ require.cache[poolPath] = {
157
+ id: poolPath, filename: poolPath, loaded: true,
158
+ exports: { pool, instanceDbName: () => 'test' },
159
+ };
160
+
161
+ const auth = require('../src/bongos/auth.js');
162
+ const buildMySessionsRouter = require('../src/bongos/routes/my-sessions.js');
163
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
164
+ const express = require('express');
165
+
166
+ const app = express();
167
+ app.use(express.json());
168
+ app.use(attachFail);
169
+ app.use('/api/bongos', buildMySessionsRouter());
170
+ const server = app.listen(0, '127.0.0.1');
171
+ await new Promise((r) => server.once('listening', r));
172
+ const BASE = `http://127.0.0.1:${server.address().port}/api/bongos`;
173
+
174
+ async function call(method, path, { token, body } = {}) {
175
+ const res = await fetch(`${BASE}${path}`, {
176
+ method,
177
+ headers: {
178
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
179
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
180
+ },
181
+ body: body === undefined ? undefined : JSON.stringify(body),
182
+ });
183
+ return { status: res.status, body: await res.json().catch(() => null) };
184
+ }
185
+
186
+ const aliveFor = (builderId) => sessions.filter((s) => s.builder_id === builderId && s.revoked_at === null).map((s) => s.id);
187
+
188
+ // ===========================================================================
189
+ // 1 — a xenos can see and kill their OWN sessions, with no Archon in sight
190
+ // ===========================================================================
191
+
192
+ console.log('own scope — a non-admin builder manages their own sessions:');
193
+
194
+ await t('GET /me/sessions lists only the caller\'s LIVE sessions', async () => {
195
+ seed(); calls = [];
196
+ const r = await call('GET', '/me/sessions', { token: 'adacli' });
197
+ assert.equal(r.status, 200);
198
+ assert.deepEqual(r.body.sessions.map((s) => s.id), ['101', '102'],
199
+ 'newest-used first; the revoked (103) and expired (104) rows must not appear, and Bo\'s 201 never can');
200
+ });
201
+
202
+ await t('the list carries enough to RECOGNISE a session, and marks the current one', async () => {
203
+ seed();
204
+ const r = await call('GET', '/me/sessions', { token: 'adacli' });
205
+ const cli = r.body.sessions.find((s) => s.id === '101');
206
+ assert.equal(cli.source, 'cli');
207
+ assert.equal(cli.user_agent, 'cloud-bongos-cli');
208
+ assert.ok(cli.created_at && cli.last_used_at && cli.expires_at, 'created / last used / expires must all be present');
209
+ assert.equal(cli.current, true, 'the session that made the request is the current one');
210
+ assert.equal(r.body.sessions.find((s) => s.id === '102').current, false);
211
+ });
212
+
213
+ await t('the token value is never selected and never returned', async () => {
214
+ seed();
215
+ const r = await call('GET', '/me/sessions', { token: 'adacli' });
216
+ const raw = JSON.stringify(r.body);
217
+ assert.ok(!/token/i.test(raw), 'no token field may appear in the list body');
218
+ for (const s of sessions) assert.ok(!raw.includes(s.token), `the raw token ${s.token} leaked into the response`);
219
+ });
220
+
221
+ await t('POST /me/sessions/revoke kills one of the caller\'s own sessions', async () => {
222
+ seed();
223
+ const r = await call('POST', '/me/sessions/revoke', { token: 'adacli', body: { session_id: 102 } });
224
+ assert.equal(r.status, 200);
225
+ assert.equal(r.body.revoked, 1);
226
+ // 104 is the already-expired row: unrevoked, so still here, but it never lists
227
+ // and its bearer can no longer authenticate. Revoking one session touches one.
228
+ assert.deepEqual(aliveFor(1), [101, 104], 'the web session is gone, the CLI one survives');
229
+ });
230
+
231
+ await t('POST /me/sessions/revoke-all signs the caller out everywhere, current session included', async () => {
232
+ seed();
233
+ const r = await call('POST', '/me/sessions/revoke-all', { token: 'adacli', body: {} });
234
+ assert.equal(r.status, 200);
235
+ assert.equal(r.body.revoked, 3, 'the two live rows plus the not-yet-expired-but-unrevoked one');
236
+ assert.deepEqual(aliveFor(1), [], 'nothing of Ada\'s is left alive');
237
+ });
238
+
239
+ await t('no rank or permission is involved — the caller is a xenos throughout', async () => {
240
+ seed();
241
+ assert.equal(BUILDERS[1].rank, 'xenos', 'the fixture must stay on the bottom rung or this proves nothing');
242
+ const list = await call('GET', '/me/sessions', { token: 'adacli' });
243
+ const kill = await call('POST', '/me/sessions/revoke-all', { token: 'adacli', body: {} });
244
+ assert.equal(list.status, 200);
245
+ assert.equal(kill.status, 200);
246
+ });
247
+
248
+ // ===========================================================================
249
+ // 2 — the same builder cannot reach anybody else's
250
+ // ===========================================================================
251
+
252
+ console.log('\ncross-builder isolation — a stranger\'s sessions stay out of reach:');
253
+
254
+ await t('a builder_id in the QUERY STRING changes nothing about whose list comes back', async () => {
255
+ seed();
256
+ const r = await call('GET', '/me/sessions?builder_id=2&builder=2', { token: 'adacli' });
257
+ assert.equal(r.status, 200);
258
+ assert.deepEqual(r.body.sessions.map((s) => s.id), ['101', '102'], 'still Ada\'s own list');
259
+ const listQuery = calls.filter((c) => /FROM builder_sessions WHERE builder_id/.test(c.sql)).pop();
260
+ assert.deepEqual(listQuery.params, [1], 'the account queried is the SESSION\'s builder, never the one named in the URL');
261
+ });
262
+
263
+ await t('guessing a stranger\'s session id revokes nothing and leaks nothing', async () => {
264
+ seed();
265
+ const r = await call('POST', '/me/sessions/revoke', { token: 'adacli', body: { session_id: 201 } });
266
+ assert.equal(r.status, 200, 'a miss is not a 404 — that would confirm which ids exist');
267
+ assert.equal(r.body.revoked, 0);
268
+ assert.deepEqual(aliveFor(2), [201], 'Bo\'s session is untouched');
269
+ assert.ok(!JSON.stringify(r.body).includes('bo'), 'nothing about the other account may come back');
270
+ });
271
+
272
+ await t('an unknown id and a stranger\'s id are indistinguishable', async () => {
273
+ seed();
274
+ const stranger = await call('POST', '/me/sessions/revoke', { token: 'adacli', body: { session_id: 201 } });
275
+ const nobody = await call('POST', '/me/sessions/revoke', { token: 'adacli', body: { session_id: 999999 } });
276
+ assert.deepEqual(stranger.body, nobody.body, 'the two answers must be byte-identical');
277
+ });
278
+
279
+ await t('naming a builder in the BODY is rejected outright, and kills nothing', async () => {
280
+ seed();
281
+ const all = await call('POST', '/me/sessions/revoke-all', { token: 'adacli', body: { builder_id: 2 } });
282
+ assert.equal(all.status, 400, 'an unknown field must fail validation rather than be quietly ignored');
283
+ const one = await call('POST', '/me/sessions/revoke', { token: 'adacli', body: { session_id: 201, builder_id: 2 } });
284
+ assert.equal(one.status, 400);
285
+ assert.deepEqual(aliveFor(2), [201], 'Bo keeps his session either way');
286
+ });
287
+
288
+ await t('Ada signing out everywhere leaves Bo signed in', async () => {
289
+ seed();
290
+ await call('POST', '/me/sessions/revoke-all', { token: 'adacli', body: {} });
291
+ assert.deepEqual(aliveFor(1), []);
292
+ assert.deepEqual(aliveFor(2), [201]);
293
+ const bo = await call('GET', '/me/sessions', { token: 'bocli' });
294
+ assert.equal(bo.status, 200);
295
+ assert.deepEqual(bo.body.sessions.map((s) => s.id), ['201']);
296
+ });
297
+
298
+ await t('every statement the routes issue is scoped to the caller\'s own account', async () => {
299
+ seed(); calls = [];
300
+ await call('GET', '/me/sessions', { token: 'adacli' });
301
+ await call('POST', '/me/sessions/revoke', { token: 'adacli', body: { session_id: 102 } });
302
+ await call('POST', '/me/sessions/revoke-all', { token: 'adacli', body: {} });
303
+ const owned = calls.filter((c) => !/AS session_id/.test(c.sql) && !/SET last_used_at/.test(c.sql));
304
+ assert.equal(owned.length, 3, 'one list, one single revoke, one sweep');
305
+ for (const c of owned) {
306
+ assert.ok(/builder_id = \$\d/.test(c.sql), `statement without an owner predicate: ${c.sql}`);
307
+ assert.ok(c.params.includes(1), `statement not bound to the caller: ${c.sql} ${JSON.stringify(c.params)}`);
308
+ assert.ok(!c.params.includes(2), 'no statement may ever carry another account\'s id');
309
+ }
310
+ });
311
+
312
+ // ===========================================================================
313
+ // 3 — a revoked bearer stops authenticating (the whole point of the feature)
314
+ // ===========================================================================
315
+
316
+ console.log('\nthe remedy actually works — a revoked bearer is dead on the next request:');
317
+
318
+ await t('an unauthenticated caller gets 401 from all three routes', async () => {
319
+ seed();
320
+ for (const [m, p] of [['GET', '/me/sessions'], ['POST', '/me/sessions/revoke'], ['POST', '/me/sessions/revoke-all']]) {
321
+ const r = await call(m, p, { body: m === 'GET' ? undefined : {} });
322
+ assert.equal(r.status, 401, `${m} ${p} must refuse an anonymous caller`);
323
+ }
324
+ });
325
+
326
+ await t('the leaked CLI bearer stops working the moment its owner revokes it', async () => {
327
+ seed();
328
+ const before = await call('GET', '/me/sessions', { token: 'adacli' });
329
+ assert.equal(before.status, 200, 'the bearer works to begin with');
330
+ // Ada kills the leaked token from her browser session, not from the CLI.
331
+ const kill = await call('POST', '/me/sessions/revoke', { token: 'adaweb', body: { session_id: 101 } });
332
+ assert.equal(kill.body.revoked, 1);
333
+ const after = await call('GET', '/me/sessions', { token: 'adacli' });
334
+ assert.equal(after.status, 401, 'the revoked bearer must no longer authenticate');
335
+ assert.equal(after.body.error && after.body.error.code, 'unauthenticated');
336
+ });
337
+
338
+ await t('sign-out-everywhere kills the session that asked for it, too', async () => {
339
+ seed();
340
+ const kill = await call('POST', '/me/sessions/revoke-all', { token: 'adacli', body: {} });
341
+ assert.equal(kill.status, 200, 'the request that was already authenticated still gets its answer');
342
+ const after = await call('GET', '/me/sessions', { token: 'adacli' });
343
+ assert.equal(after.status, 401);
344
+ const web = await call('GET', '/me/sessions', { token: 'adaweb' });
345
+ assert.equal(web.status, 401, 'the browser cookie is gone as well — that is what "everywhere" means');
346
+ });
347
+
348
+ await t('refresh-on-use cannot resurrect a revoked session', async () => {
349
+ seed();
350
+ await call('POST', '/me/sessions/revoke', { token: 'adaweb', body: { session_id: 101 } });
351
+ await call('GET', '/me/sessions', { token: 'adacli' }); // the leaked token keeps trying
352
+ const row = sessions.find((s) => s.id === 101);
353
+ assert.ok(row.revoked_at instanceof Date, 'still revoked');
354
+ const r = await call('GET', '/me/sessions', { token: 'adacli' });
355
+ assert.equal(r.status, 401, 'no amount of use walks the expiry forward once revoked_at is set');
356
+ });
357
+
358
+ // ===========================================================================
359
+ // 4 — the source itself never takes an owner from the request
360
+ // ===========================================================================
361
+
362
+ console.log('\nroute source structure — the owner can only come from the session:');
363
+
364
+ const ROUTE_SRC = readFileSync(join(ROOT, 'src/bongos/routes/my-sessions.js'), 'utf8');
365
+
366
+ await t('no owner is ever read from the query string, the body, or the path', () => {
367
+ // Asserted over the RAW source, comments included: this file is own-scoped
368
+ // end to end, so unlike the mixed route files there is no legitimate reason
369
+ // for any of these spellings to appear in it at all.
370
+ for (const bad of [/req\.query\.builder/i, /req\.body\.builder/i, /req\.body\.owner/i, /req\.params\.builderId/i]) {
371
+ assert.ok(!bad.test(ROUTE_SRC), `my-sessions.js must not contain ${bad}`);
372
+ }
373
+ });
374
+
375
+ await t('every db call in the file passes req.builder.id', () => {
376
+ const dbCalls = ROUTE_SRC.split('\n')
377
+ .map((line) => /db\.[A-Za-z]+\((.*)\)/.exec(line))
378
+ .filter(Boolean)
379
+ .map((m) => m[1]);
380
+ assert.equal(dbCalls.length, 3, 'three db calls: list, revoke one, revoke all');
381
+ for (const args of dbCalls) {
382
+ assert.ok(/req\.builder\.id/.test(args), `db call not bound to the session builder: (${args})`);
383
+ }
384
+ });
385
+
386
+ await t('the admin cross-builder pair is untouched and still permission-gated', () => {
387
+ const authSrc = readFileSync(join(ROOT, 'src/bongos/routes/auth.js'), 'utf8');
388
+ for (const route of ["'/auth/revoke'", "'/auth/revoke-all'"]) {
389
+ const i = authSrc.indexOf(route);
390
+ assert.ok(i > 0, `${route} must still be declared`);
391
+ assert.ok(/requirePermission\('auth\.session\.revoke'\)/.test(authSrc.slice(i, i + 220)),
392
+ `${route} must keep its admin gate — the own-scoped routes are an addition, not a replacement`);
393
+ }
394
+ });
395
+
396
+ await t('the three own-scoped routes are pinned to any-builder in the rank auditor', () => {
397
+ const { checkRouteRanks } = require('../src/bongos/route-rank-check.js');
398
+ const r = checkRouteRanks();
399
+ assert.equal(r.hardFail, false, 'no route may be left unclassified or downgraded');
400
+ const pinSrc = readFileSync(join(ROOT, 'src/bongos/route-rank-check.js'), 'utf8');
401
+ for (const route of ['GET /me/sessions', 'POST /me/sessions/revoke', 'POST /me/sessions/revoke-all']) {
402
+ assert.ok(pinSrc.includes(`'${route}': 'any-builder'`), `${route} must be pinned so a later gate change fails loud`);
403
+ }
404
+ });
405
+
406
+ // ===========================================================================
407
+
408
+ server.close();
409
+ console.log(`\n${passed} passed, ${failed} failed`);
410
+ process.exit(failed === 0 ? 0 : 1);