@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.
- package/.bongos-core.json +87 -32
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +22 -0
- package/clients/bongos-client/index.cjs +22 -0
- package/clients/bongos-client/index.d.ts +33 -0
- package/clients/bongos-client/index.mjs +22 -0
- package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +59 -0
- package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +599 -3
- package/docs/api-reference.md +17 -2
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +51 -36
- package/docs/copy-registry.json +175 -40
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +22 -0
- package/modules/hall-ui/public/settings-sessions.js +119 -0
- package/modules/hall-ui/public/settings.css +25 -0
- package/modules/hall-ui/public/settings.html +22 -0
- package/modules/platform-identity/account-visibility.js +58 -0
- package/modules/platform-identity/connections.js +490 -0
- package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/connections.js +236 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/db.js +46 -0
- package/src/bongos/route-rank-check.js +10 -0
- package/src/bongos/routes/my-sessions.js +128 -0
- package/src/bongos/routes.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/connections_api.mjs +568 -0
- package/tests/connections_ownership.mjs +313 -0
- package/tests/own_scope_guard.mjs +10 -0
- 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.
|
|
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.
|
|
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.
|
|
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
|
+
};
|
package/src/bongos/routes.js
CHANGED
|
@@ -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.
|
|
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');
|