@bongos/core 1.19.416 → 1.19.418

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 (35) hide show
  1. package/.bongos-core.json +70 -35
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +10 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0205-federated-checkin-needs-the-hubs-own-signin-witness.md +79 -0
  8. package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +59 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +185 -4
  11. package/docs/api-reference.md +5 -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 +6 -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/migrations/platform_identity_014_membership_signin_proof.sql +41 -0
  22. package/modules/platform-identity/platform-identity.js +88 -15
  23. package/modules/platform-identity/routes/sso.js +54 -10
  24. package/modules/platform-identity/tests/platform-identity.mjs +25 -6
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/src/bongos/db.js +46 -0
  28. package/src/bongos/route-rank-check.js +10 -0
  29. package/src/bongos/routes/my-sessions.js +128 -0
  30. package/src/bongos/routes.js +5 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/hub_recent_ships.mjs +4 -1
  33. package/tests/own_scope_guard.mjs +5 -0
  34. package/tests/own_session_revoke.mjs +410 -0
  35. package/tests/sso_checkin_identity_spoof.mjs +348 -0
@@ -197,6 +197,16 @@ const EXPECTED_RANKS = {
197
197
  // session revocation (kill another builder's / all sessions)
198
198
  'POST /auth/revoke': 'perm:auth.session.revoke',
199
199
  'POST /auth/revoke-all': 'perm:auth.session.revoke',
200
+ // deliberately any-builder (task 1003341) — the OWN-scoped half of the same
201
+ // capability. These reach only the caller's own sessions (every statement is
202
+ // keyed on req.builder.id), so a rank floor here would just put an Archon back
203
+ // between a person and their own leaked token, which is the gap they close.
204
+ // Pinned in BOTH directions: an accidental rank/permission gate would silently
205
+ // re-break the self-serve remedy, and a widening to `public` would hand an
206
+ // anonymous caller a sign-out button.
207
+ 'GET /me/sessions': 'any-builder',
208
+ 'POST /me/sessions/revoke': 'any-builder',
209
+ 'POST /me/sessions/revoke-all': 'any-builder',
200
210
  // forensic surfaces
201
211
  'GET /audit-log': 'perm:audit_log.read',
202
212
  'GET /builders/roster': 'perm:builder.roster.read',
@@ -0,0 +1,128 @@
1
+ // src/bongos/routes/my-sessions.js — a builder's own auth sessions: see them, kill them.
2
+ //
3
+ // WHY. A CLI bearer refreshes to now()+24h on every authenticated call (the
4
+ // refresh-on-use UPDATE in db-kernel.js lookupSession), so a token that keeps
5
+ // being used never expires on its own. Until these routes existed the only
6
+ // remedy for a LEAKED one was POST /auth/revoke-all, gated on the admin
7
+ // `auth.session.revoke` atom — so the victim had to find an Archon to sign them
8
+ // out, and a single-owner self-hosted instance may not have a second Archon at
9
+ // all. That is the gap this file closes: the account holder gets the remedy
10
+ // directly, without anybody's permission but their own.
11
+ //
12
+ // THE INVARIANT, and the only thing that really matters when editing this file:
13
+ // the account is ALWAYS req.builder.id, resolved by requireBuilder from the
14
+ // session the caller actually presented. It is never read from the query
15
+ // string, the request body, or the path. Every statement underneath carries
16
+ // `builder_id = <caller>` in its WHERE clause, so an id belonging to somebody
17
+ // else matches no row: a caller who guesses a stranger's session id gets
18
+ // `revoked: 0` and learns nothing about whether it existed.
19
+ //
20
+ // WHY ITS OWN FILE rather than three more routes in routes/auth.js. The
21
+ // cross-builder admin pair next door legitimately takes its target from the
22
+ // request body — that is what makes it an admin route. Keeping the own-scoped
23
+ // half in a separate file lets tests/own_session_revoke.mjs assert the
24
+ // no-caller-named-id rule over this source WHOLE, with no carve-out for a
25
+ // neighbouring route to hide behind. routes/auth.js is also the largest file in
26
+ // the directory; this work does not belong in it.
27
+ //
28
+ // The token value is never selected, never logged, and never returned — a list
29
+ // that handed back tokens would turn one stolen session into all of them.
30
+ //
31
+ // The 24h sliding refresh itself is deliberately left alone here; ADR 0206
32
+ // records why an absolute ceiling is a separate decision from this remedy.
33
+
34
+ const express = require('express');
35
+ const auth = require('../auth');
36
+ const db = require('../db');
37
+ const { validateOrRespond } = require('./_helpers');
38
+ const log = require('../logger').logger.child({ src: 'my-sessions' });
39
+
40
+ // A bigserial id as the client may spell it. Matched (and then PASSED) as a
41
+ // string rather than coerced through Number: a bigint above 2^53 would lose
42
+ // precision on the way through, and a parameterized comparison against a bigint
43
+ // column takes the text form without complaint.
44
+ const SESSION_ID_RE = /^[0-9]{1,19}$/;
45
+
46
+ // The list shape a person needs to RECOGNISE one of their own sessions: where it
47
+ // came from, when it was minted, when it was last used, what client held it —
48
+ // plus which row is the very request being served, so the UI can warn before
49
+ // somebody signs out the browser they are reading the page in.
50
+ function presentSession(row, currentSessionId) {
51
+ return {
52
+ id: String(row.id),
53
+ source: row.source,
54
+ user_agent: row.user_agent ?? null,
55
+ created_at: row.created_at,
56
+ last_used_at: row.last_used_at,
57
+ expires_at: row.expires_at,
58
+ current: currentSessionId !== null && String(row.id) === currentSessionId,
59
+ };
60
+ }
61
+
62
+ // The id of the session THIS request authenticated with, as a string, or null
63
+ // when the auth layer did not leave one (no live row can then be marked current,
64
+ // which is the safe direction — the UI simply omits the "this is you" badge).
65
+ function currentSessionId(req) {
66
+ const id = req.gdsSession && req.gdsSession.session_id;
67
+ return id === undefined || id === null ? null : String(id);
68
+ }
69
+
70
+ module.exports = function buildMySessionsRouter() {
71
+ const router = express.Router();
72
+
73
+ // GET /api/bongos/me/sessions — every live session on the CALLER's account.
74
+ // rank: any-builder (own identity only) — a builder owns their own sessions;
75
+ // the account is req.builder.id, never a caller-named one, so there is nothing
76
+ // here for a rank gate to protect.
77
+ router.get('/me/sessions', auth.requireBuilder, async (req, res) => {
78
+ try {
79
+ const rows = await db.listActiveSessionsForBuilder(req.builder.id);
80
+ const current = currentSessionId(req);
81
+ res.json({ sessions: rows.map((row) => presentSession(row, current)) });
82
+ } catch (err) {
83
+ log.error({ err: err && err.message }, 'GET /me/sessions failed');
84
+ res.fail('list_failed', { status: 500, message: 'internal error' });
85
+ }
86
+ });
87
+
88
+ // POST /api/bongos/me/sessions/revoke — kill ONE of the caller's own sessions.
89
+ // Body: { session_id }. Response: { ok, revoked: 0|1 }. Revoking the session
90
+ // that made the request is allowed and immediate: the response is written from
91
+ // a request that already authenticated, and the next call with that token
92
+ // fails. `revoked: 0` covers "already gone", "never existed" and "not yours"
93
+ // alike — the caller must not be able to probe for other people's session ids.
94
+ // rank: any-builder (own identity only) — the WHERE clause is the boundary.
95
+ router.post('/me/sessions/revoke', auth.requireBuilder, async (req, res) => {
96
+ if (validateOrRespond(req, res, { session_id: {} })) return;
97
+ const raw = req.body && req.body.session_id;
98
+ if (raw === undefined || raw === null || !SESSION_ID_RE.test(String(raw))) {
99
+ return res.fail('bad_session_id', { status: 400, message: 'session_id must be a positive integer id from GET /me/sessions' });
100
+ }
101
+ try {
102
+ const revoked = await db.revokeSessionForBuilder(String(raw), req.builder.id);
103
+ res.json({ ok: true, revoked });
104
+ } catch (err) {
105
+ log.error({ err: err && err.message }, 'POST /me/sessions/revoke failed');
106
+ res.fail('revoke_failed', { status: 500, message: 'internal error' });
107
+ }
108
+ });
109
+
110
+ // POST /api/bongos/me/sessions/revoke-all — sign out everywhere. Kills EVERY
111
+ // live session on the caller's account, the one making the request included.
112
+ // That total sweep is the point: this is the button somebody presses when they
113
+ // believe a token has leaked, and a remedy that spared one session would leave
114
+ // them guessing which. They sign in again afterwards. Response: { ok, revoked }.
115
+ // rank: any-builder (own identity only) — the sweep is keyed on req.builder.id.
116
+ router.post('/me/sessions/revoke-all', auth.requireBuilder, async (req, res) => {
117
+ if (validateOrRespond(req, res, {})) return;
118
+ try {
119
+ const revoked = await db.revokeAllSessionsForBuilder(req.builder.id);
120
+ res.json({ ok: true, revoked });
121
+ } catch (err) {
122
+ log.error({ err: err && err.message }, 'POST /me/sessions/revoke-all failed');
123
+ res.fail('revoke_all_failed', { status: 500, message: 'internal error' });
124
+ }
125
+ });
126
+
127
+ return router;
128
+ };
@@ -22,6 +22,10 @@ const buildInstanceRouter = require('./routes/instance');
22
22
  const buildAuthRouter = require('./routes/auth');
23
23
  const buildGithubRouter = require('./routes/github');
24
24
  const buildMeRouter = require('./routes/me');
25
+ // The own-scoped session self-service (list / revoke one / sign out everywhere,
26
+ // task 1003341). Its own router rather than three more routes on the /me
27
+ // aggregator or the admin pair in routes/auth.js — see that file's header.
28
+ const buildMySessionsRouter = require('./routes/my-sessions');
25
29
  const buildBuildersRouter = require('./routes/builders');
26
30
  // The LIFECYCLE routes (tasks · claims · versions · done-when · goals ·
27
31
  // dependencies · gate-approvals · analytics) carved into modules/lifecycle/
@@ -124,6 +128,7 @@ function buildGdsRouter() {
124
128
  router.use(buildAuthRouter());
125
129
  router.use(buildGithubRouter());
126
130
  router.use(buildMeRouter());
131
+ router.use(buildMySessionsRouter());
127
132
  router.use(buildBuildersRouter());
128
133
  // tasks · claims · versions · done-when · goals · dependencies · analytics now
129
134
  // mount via moduleLoader.mountModuleRoutes below (lifecycle module, BV1.R86) —
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.416'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.418'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -163,7 +163,10 @@ await t('the route passes recent_ships through and takes the origin from the CLI
163
163
  return { rows: [{ client_id: 'c1', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: ORIGIN }] };
164
164
  }
165
165
  if (/FROM platform_identity_project_memberships/.test(sql)) {
166
- return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member' }] };
166
+ // signin_verified_at is what ADR 0205 made the rollup gate read: a
167
+ // membership row alone was never proof, because the check-in that wrote it
168
+ // is the caller's own unverified claim. A real sign-in stamps it.
169
+ return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member', signin_verified_at: '2026-08-29T00:00:00.000Z' }] };
167
170
  }
168
171
  return { rows: [{ id: 1 }] };
169
172
  });
@@ -79,6 +79,11 @@ const OWN_SCOPED_SURFACES = [
79
79
  why: 'the /me aggregator: identity, rank, credits, streak, claims and prefs for the CALLER. Every slice keys on req.builder.id.',
80
80
  crossOwner: [],
81
81
  },
82
+ {
83
+ file: 'src/bongos/routes/my-sessions.js',
84
+ why: "the caller's OWN auth sessions — list, revoke one, sign out everywhere (task 1003341, ADR 0206). Every statement carries builder_id = req.builder.id, so a session id belonging to somebody else matches no row. Kept OUT of routes/auth.js precisely so this whole file can be held to the own-scope rule: the admin cross-builder pair next door takes its target from the body, which is what makes it admin.",
85
+ crossOwner: [],
86
+ },
82
87
  {
83
88
  file: 'modules/builder-settings/routes/settings.js',
84
89
  why: 'the per-builder preference domain (wandering, skill, sound, render, model-allocation, display name) carved out of /me — read and write, all keyed on req.builder.id.',
@@ -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);