@bongos/core 1.19.418 → 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.
@@ -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.418",
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.418",
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.418",
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/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.418'; // 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');