@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,236 @@
1
+ // modules/platform-identity/routes/connections.js — the connection graph API
2
+ // (R19, task 1002295 / goal 1000045 criterion C6; ADR 0202).
3
+ //
4
+ // EVERY ROUTE HERE IS OWN-SCOPED. The acting account is always
5
+ // req.builder.github_id — the session — and no route reads its subject from a
6
+ // query, body or path parameter. The counterparty is named by HANDLE (the
7
+ // public address, ADR 0171 D8) or by the connection row's own id; neither is a
8
+ // way to act AS someone else. tests/own_scope_guard.mjs holds that structurally.
9
+ //
10
+ // POST /connections — ask. Body { handle, note? }. 201
11
+ // { status: 'pending' } whether the row
12
+ // was written or SWALLOWED by the 90-day
13
+ // decline cooldown: the two answers are
14
+ // identical on purpose (ADR 0202 D2 §2).
15
+ // 404 — not 403 — for a private or absent
16
+ // target, because 403 confirms the handle.
17
+ // GET /connections — my accepted connections. count is the
18
+ // array's own length (no hidden remainder).
19
+ // GET /connections/requests — my inbox + outbox; stamps seen_at.
20
+ // GET /connections/degree/:handle — the viewer-relative label 0|1|2|3|null
21
+ // plus the mutuals it is computed from.
22
+ // An OWN-SCOPE read (D4 §4): this is how a
23
+ // viewer-relative number coexists with the
24
+ // viewer-independent public profile read.
25
+ // POST /connections/:id/accept — addressee only. 404 for anyone else.
26
+ // POST /connections/:id/decline — addressee only, SILENT (204). 404 else.
27
+ // DELETE /connections/:id — remove an accepted row (either side) or
28
+ // cancel a pending one (requester only).
29
+ //
30
+ // A CONNECTION IS NEVER AUTHORITY (ADR 0016 / ADR 0141). Nothing here reads or
31
+ // writes rank, credits, admission or permission; the numbers are descriptive.
32
+ //
33
+ // no-store on every response: these payloads are privacy-filtered per viewer and
34
+ // a hide or a visibility flip must beat any cache (ADR 0171 D5).
35
+ //
36
+ // Reaches core only through the doorway (ADR 0083).
37
+ 'use strict';
38
+
39
+ const express = require('express');
40
+ const api = require('../../../src/module-api');
41
+ const pi = require('../platform-identity');
42
+ const conn = require('../connections');
43
+ const { isAccountVisible } = require('../account-visibility');
44
+
45
+ const { pool, requireBuilder, validateOrRespond, parsePagination } = api;
46
+ const log = api.logger('platform-identity');
47
+
48
+ // A connection row id is a bigserial. Anything else is answered 404 rather than
49
+ // 400, for the same reason a wrong-owner id is: the id space must not be probeable.
50
+ // The length cap is load-bearing, not cosmetic: a 25-digit string is all digits
51
+ // and would reach Postgres, where it raises 22003 (bigint out of range) and
52
+ // surfaces as a 500 — a different answer for a different id, i.e. a probe.
53
+ function connectionId(req) {
54
+ return /^\d{1,19}$/.test(String(req.params.id || '')) ? req.params.id : null;
55
+ }
56
+
57
+ // The caller's platform account, or a { fail } describing why they cannot take
58
+ // part. profile_state = 'active' is the floor for the WHOLE feature (ADR 0202
59
+ // D4 §1): a provisional account cannot send, cannot receive, and never appears
60
+ // in a list or a frontier. This is about the CALLER, so it may say so plainly.
61
+ async function actingAccount(builder) {
62
+ const githubId = builder && builder.github_id;
63
+ if (!pi.isNumericGithubId(githubId)) {
64
+ return { fail: ['no_platform_identity', { status: 400, message: 'this session has no platform account' }] };
65
+ }
66
+ const acct = await pi.getAccount(githubId, { pool });
67
+ if (!acct || acct.profile_state !== 'active') {
68
+ return { fail: ['profile_provisional', { status: 403, message: 'sign in to the platform once to activate your profile before connecting' }] };
69
+ }
70
+ return { githubId };
71
+ }
72
+
73
+ // Resolve a handle to a counterparty THIS VIEWER may address: publicly visible,
74
+ // or a private account the viewer is already connected to (ADR 0202 D4 §2).
75
+ // Everything else — unknown handle, provisional account, private stranger — is
76
+ // the same null, and the caller answers 404 for all of them.
77
+ async function visibleTarget(viewerGithubId, handle) {
78
+ const acct = await conn.findAccountByHandle(handle, { pool });
79
+ if (!acct) return null;
80
+ if (isAccountVisible(acct)) return acct;
81
+ if (acct.profile_state !== 'active') return null;
82
+ return (await conn.isConnectedTo(viewerGithubId, acct.github_id, { pool })) ? acct : null;
83
+ }
84
+
85
+ module.exports = function connectionRoutes() {
86
+ const router = express.Router();
87
+
88
+ // One prefix covers every route below (/connections and /connections/...).
89
+ router.use('/connections', (req, res, next) => {
90
+ res.set('Cache-Control', 'no-store');
91
+ next();
92
+ });
93
+
94
+ // rank: any-builder — MY accepted connections (own data, keyed on the session).
95
+ // Own scope, so the counterparty floors on profile_state alone: a private
96
+ // account you connected with appears here and in no public list (D4 §2).
97
+ router.get('/connections', requireBuilder, async (req, res) => {
98
+ try {
99
+ const me = await actingAccount(req.builder);
100
+ if (me.fail) return res.fail(...me.fail);
101
+ const { limit, offset } = parsePagination(req, { defaultLimit: conn.FANOUT_1, maxLimit: conn.FANOUT_1 });
102
+ const connections = await conn.listConnections({ githubId: me.githubId, limit, offset }, { pool });
103
+ // The count IS the array's length — never a raw total beside a filtered
104
+ // list, which is a leak by subtraction (ADR 0171 D4 / ADR 0202 D4).
105
+ res.json({ connections, count: connections.length });
106
+ } catch (err) {
107
+ log.error({ err: err && err.message }, 'GET /connections failed');
108
+ res.fail('connections_read_failed', { status: 500, message: 'internal error' });
109
+ }
110
+ });
111
+
112
+ // rank: any-builder — MY pending requests, both directions (own data).
113
+ router.get('/connections/requests', requireBuilder, async (req, res) => {
114
+ try {
115
+ const me = await actingAccount(req.builder);
116
+ if (me.fail) return res.fail(...me.fail);
117
+ const { incoming, outgoing } = await conn.listRequests({ githubId: me.githubId }, { pool });
118
+ res.json({ incoming, outgoing, incoming_count: incoming.length, outgoing_count: outgoing.length });
119
+ } catch (err) {
120
+ log.error({ err: err && err.message }, 'GET /connections/requests failed');
121
+ res.fail('connection_requests_read_failed', { status: 500, message: 'internal error' });
122
+ }
123
+ });
124
+
125
+ // rank: any-builder — the degree label between the CALLER and one handle, plus
126
+ // the mutuals it was computed from (own-scope read, D4 §4). Bounded by
127
+ // construction: degreeBetween caps every level and answers null past the caps.
128
+ router.get('/connections/degree/:handle', requireBuilder, async (req, res) => {
129
+ try {
130
+ const me = await actingAccount(req.builder);
131
+ if (me.fail) return res.fail(...me.fail);
132
+ const target = await visibleTarget(me.githubId, req.params.handle);
133
+ if (!target) return res.fail('connection_target_not_found', 404);
134
+ const out = await conn.degreeBetween(me.githubId, target.github_id, { pool });
135
+ res.json({
136
+ handle: target.handle,
137
+ degree: out.degree,
138
+ mutuals: out.mutuals,
139
+ mutual_count: out.mutuals.length,
140
+ truncated: out.truncated,
141
+ });
142
+ } catch (err) {
143
+ log.error({ err: err && err.message }, 'GET /connections/degree/:handle failed');
144
+ res.fail('connection_degree_failed', { status: 500, message: 'internal error' });
145
+ }
146
+ });
147
+
148
+ // rank: any-builder — ask to connect. The REQUESTER is always the session; the
149
+ // body names only who is being asked, and it names them by public handle.
150
+ router.post('/connections', requireBuilder, async (req, res) => {
151
+ if (validateOrRespond(req, res, {
152
+ handle: { required: true, type: 'string', minLength: 1, maxLength: 39 },
153
+ note: { type: 'string', maxLength: conn.NOTE_MAX_CHARS },
154
+ })) return;
155
+ try {
156
+ const me = await actingAccount(req.builder);
157
+ if (me.fail) return res.fail(...me.fail);
158
+ const target = await visibleTarget(me.githubId, req.body.handle);
159
+ // Absent, provisional and private-stranger are ONE answer. A 403 would
160
+ // confirm the handle exists, which is the whole thing private protects.
161
+ if (!target) return res.fail('connection_target_not_found', 404);
162
+ if (String(target.github_id) === String(me.githubId)) {
163
+ return res.fail('cannot_connect_to_self', { status: 400, message: 'that is your own account' });
164
+ }
165
+ const out = await conn.requestConnection({
166
+ requesterGithubId: me.githubId,
167
+ addresseeGithubId: target.github_id,
168
+ note: req.body.note || null,
169
+ }, { pool });
170
+ if (out.outcome === 'already_open') {
171
+ return res.fail('connection_already_open', { status: 409, message: 'there is already a request or a connection between you' });
172
+ }
173
+ // 'created' and 'swallowed' answer IDENTICALLY — no id, no timestamps,
174
+ // nothing a caller could difference to learn they were declined before.
175
+ res.status(201).json({ status: 'pending' });
176
+ } catch (err) {
177
+ log.error({ err: err && err.message }, 'POST /connections failed');
178
+ res.fail('connection_request_failed', { status: 500, message: 'internal error' });
179
+ }
180
+ });
181
+
182
+ // rank: any-builder — accept a request addressed to ME. The addressee is the
183
+ // session; a row belonging to anyone else simply does not match, and the 404
184
+ // that follows is what stops a builder acting on someone else's connection.
185
+ router.post('/connections/:id/accept', requireBuilder, async (req, res) => {
186
+ const id = connectionId(req);
187
+ if (!id) return res.fail('connection_request_not_found', 404);
188
+ try {
189
+ const me = await actingAccount(req.builder);
190
+ if (me.fail) return res.fail(...me.fail);
191
+ const ok = await conn.acceptConnection({ id, githubId: me.githubId }, { pool });
192
+ if (!ok) return res.fail('connection_request_not_found', 404);
193
+ res.json({ status: 'accepted' });
194
+ } catch (err) {
195
+ log.error({ err: err && err.message }, 'POST /connections/:id/accept failed');
196
+ res.fail('connection_accept_failed', { status: 500, message: 'internal error' });
197
+ }
198
+ });
199
+
200
+ // rank: any-builder — decline a request addressed to ME. 204: the requester is
201
+ // never told, so there is nothing to return that they could ever observe.
202
+ router.post('/connections/:id/decline', requireBuilder, async (req, res) => {
203
+ const id = connectionId(req);
204
+ if (!id) return res.fail('connection_request_not_found', 404);
205
+ try {
206
+ const me = await actingAccount(req.builder);
207
+ if (me.fail) return res.fail(...me.fail);
208
+ const ok = await conn.declineConnection({ id, githubId: me.githubId }, { pool });
209
+ if (!ok) return res.fail('connection_request_not_found', 404);
210
+ res.status(204).end();
211
+ } catch (err) {
212
+ log.error({ err: err && err.message }, 'POST /connections/:id/decline failed');
213
+ res.fail('connection_decline_failed', { status: 500, message: 'internal error' });
214
+ }
215
+ });
216
+
217
+ // rank: any-builder — remove an accepted connection (either side may end it)
218
+ // or cancel a pending request I sent. An ADDRESSEE ending a pending row is a
219
+ // decline, not a delete, and is 404'd here so the cooldown cannot be dodged.
220
+ router.delete('/connections/:id', requireBuilder, async (req, res) => {
221
+ const id = connectionId(req);
222
+ if (!id) return res.fail('connection_not_found', 404);
223
+ try {
224
+ const me = await actingAccount(req.builder);
225
+ if (me.fail) return res.fail(...me.fail);
226
+ const outcome = await conn.removeConnection({ id, githubId: me.githubId }, { pool });
227
+ if (!outcome) return res.fail('connection_not_found', 404);
228
+ res.json({ outcome });
229
+ } catch (err) {
230
+ log.error({ err: err && err.message }, 'DELETE /connections/:id failed');
231
+ res.fail('connection_remove_failed', { status: 500, message: 'internal error' });
232
+ }
233
+ });
234
+
235
+ return router;
236
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.417",
3
+ "version": "1.19.419",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.417",
9
+ "version": "1.19.419",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.417",
3
+ "version": "1.19.419",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/bongos/db.js CHANGED
@@ -733,6 +733,50 @@ async function revokeAllSessionsForBuilder(builderId) {
733
733
  return rowCount;
734
734
  }
735
735
 
736
+ // ---------- own-scoped session self-service (task 1003341) ----------
737
+ //
738
+ // The three helpers below are the account holder's OWN remedy for a leaked
739
+ // bearer, and every one of them carries `builder_id = $caller` in its WHERE
740
+ // clause. That predicate is the isolation: an id belonging to somebody else
741
+ // matches no row, so a caller who guesses a stranger's session id learns
742
+ // nothing and changes nothing. src/bongos/routes/my-sessions.js is the only
743
+ // caller and always passes req.builder.id — never a client-supplied value.
744
+
745
+ // listActiveSessionsForBuilder — the live (unrevoked, unexpired) sessions on one
746
+ // account, newest-used first. Returns only what a person needs to RECOGNISE a
747
+ // session — where it came from, when it was minted, when it was last used, what
748
+ // client held it. The `token` column is deliberately never selected: a list that
749
+ // returned tokens would turn one stolen session into all of them.
750
+
751
+ async function listActiveSessionsForBuilder(builderId) {
752
+ const { rows } = await pool.query(
753
+ `SELECT id, source, user_agent, created_at, last_used_at, expires_at
754
+ FROM builder_sessions
755
+ WHERE builder_id = $1
756
+ AND revoked_at IS NULL
757
+ AND (expires_at IS NULL OR expires_at > now())
758
+ ORDER BY last_used_at DESC, id DESC`,
759
+ [builderId]
760
+ );
761
+ return rows;
762
+ }
763
+
764
+ // revokeSessionForBuilder — kill ONE session the caller owns. The own-scoped twin
765
+ // of revokeSessionById: same effect, but it can only ever reach a row on the
766
+ // caller's own account. Returns 1 when a session was killed, 0 when the id was
767
+ // already revoked, expired, unknown, or somebody else's — the four cases are
768
+ // deliberately indistinguishable to the caller.
769
+
770
+ async function revokeSessionForBuilder(sessionId, builderId) {
771
+ const { rowCount } = await pool.query(
772
+ `UPDATE builder_sessions
773
+ SET revoked_at = now()
774
+ WHERE id = $1 AND builder_id = $2 AND revoked_at IS NULL`,
775
+ [sessionId, builderId]
776
+ );
777
+ return rowCount;
778
+ }
779
+
736
780
  // -------------------------------------------------------------------------
737
781
  // Versions
738
782
  // -------------------------------------------------------------------------
@@ -994,6 +1038,8 @@ module.exports = {
994
1038
  revokeSession,
995
1039
  revokeSessionById,
996
1040
  revokeAllSessionsForBuilder,
1041
+ listActiveSessionsForBuilder,
1042
+ revokeSessionForBuilder,
997
1043
  hasCliSession,
998
1044
  SESSION_TTL_MS,
999
1045
  LIVE_RANK_LADDER,
@@ -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.417'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.419'; // 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');