@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,490 @@
1
+ // modules/platform-identity/connections.js — the connection graph domain
2
+ // (task 1002295 / goal 1000045 criterion C6; ADR 0202, which this implements).
3
+ //
4
+ // WHAT LIVES HERE: the request/accept/decline/cancel/remove lifecycle over
5
+ // platform_identity_connections, the two own-scope list reads (my connections,
6
+ // my inbox+outbox), and the BOUNDED degree-of-separation query behind a
7
+ // profile's "2nd" label. Every rule the ADR made load-bearing is enforced HERE,
8
+ // next to the data, so no route can forget one:
9
+ //
10
+ // * a decline is SILENT and opens a 90-day cooldown in which a re-ask is
11
+ // SWALLOWED — nothing written, and the caller gets the same answer a real
12
+ // create gets (ADR 0202 D2; ADR 0194 D3 applied to a person);
13
+ // * a pending request EXPIRES at 30 days read-time, and the write path
14
+ // resolves an expired row of the pair in the SAME statement that inserts the
15
+ // new one — now() cannot live in an index predicate, so a stale pending row
16
+ // would otherwise keep the partial unique index locked forever;
17
+ // * REMOVE hard-deletes; 'rejected' is kept because it still does work;
18
+ // * degree expansion is CAPPED (FANOUT_1, FANOUT_2, DEGREE_MAX) with a
19
+ // deterministic frontier order, and past the caps the label is null — "out
20
+ // of network", never a number the query did not establish (ADR 0202 D3);
21
+ // * every count returned is the LENGTH OF THE ARRAY the viewer may see. No
22
+ // raw count beside a filtered list — that is a leak by subtraction
23
+ // (ADR 0171 D4 / ADR 0202 D4).
24
+ //
25
+ // WHOSE GRAPH. Node visibility is viewer-relative by design (ADR 0202 title):
26
+ // the viewer's OWN connections floor on profile_state='active' alone — a private
27
+ // account that accepted you appears in your own-scope list (D4 §2) — while every
28
+ // DEEPER hop floors on the full public predicate in account-visibility.js. So a
29
+ // bridge the viewer may not see yields `null`, not "3rd", and two viewers
30
+ // legitimately seeing different labels for the same target is the model working.
31
+ //
32
+ // NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank, credit, admission or
33
+ // permission path reads a connection; accepting one changes nobody's access.
34
+ //
35
+ // Reaches core only through the doorway (ADR 0083).
36
+ 'use strict';
37
+
38
+ const api = require('../../src/module-api');
39
+ const { accountVisibleSql, accountActiveSql } = require('./account-visibility');
40
+
41
+ const { pool: defaultPool } = api;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // The constants ADR 0202 D2/D3 named. Exported rather than spelled inline so a
45
+ // route, a test and a doc read the same number.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ // The lifecycle vocabulary (mirrors the migration's CHECK — the two must not drift).
49
+ const CONNECTION_STATUSES = ['pending', 'accepted', 'rejected', 'cancelled', 'expired'];
50
+
51
+ // A pending request is gone at 30 days, computed at read/write time — the
52
+ // privacy spec's D4 constant and mechanism, no sweeper.
53
+ const REQUEST_EXPIRY_DAYS = 30;
54
+
55
+ // A decline closes the door for 90 days. Inside it, a re-ask from the SAME
56
+ // requester is swallowed: nothing written, and the response is the one a real
57
+ // create returns, because a 409 saying "declined" would disclose exactly what
58
+ // the silence protects.
59
+ const DECLINE_COOLDOWN_DAYS = 90;
60
+
61
+ // The optional one-line "why I'm asking" on a request. Bio-sized (the P02
62
+ // precedent) — long enough to be a sentence, short enough not to be a message
63
+ // channel, which connections deliberately are not.
64
+ const NOTE_MAX_CHARS = 280;
65
+
66
+ // Bounded expansion (D3). Past DEGREE_MAX hops, or past a fan-out cap, the label
67
+ // is null. FANOUT_1 bounds a single account's 1-hop; FANOUT_2 bounds the 2-hop
68
+ // frontier the 3rd-degree test intersects against.
69
+ const DEGREE_MAX = 3;
70
+ const FANOUT_1 = 500;
71
+ const FANOUT_2 = 5000;
72
+
73
+ // What a counterparty row carries: exactly what a name in a list needs (ADR 0202
74
+ // Deferred — "handle, display name, avatar"). github_id stays internal, and
75
+ // github_login is deliberately absent: a private account that connected with you
76
+ // did not thereby publish its GitHub identity to you.
77
+ const COUNTERPARTY_COLUMNS = 'pa.handle, pa.display_name, pa.avatar_url';
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // The 1-hop, written once
81
+ // ---------------------------------------------------------------------------
82
+
83
+ // The accepted-neighbour set of ONE account, as a SQL fragment over the two
84
+ // directional indexes (never `pair_lo = x OR pair_hi = x`, which cannot use
85
+ // either). `param` is the $n placeholder holding that account's github_id.
86
+ // Written once so no caller invents a direction-aware WHERE of its own — the
87
+ // symmetry of an accepted edge is a property of the schema, not of a route.
88
+ function neighboursSql(param) {
89
+ if (!/^\$\d+$/.test(String(param))) {
90
+ throw new Error('neighboursSql: param must be a $n placeholder');
91
+ }
92
+ return `
93
+ SELECT addressee_github_id AS gid FROM platform_identity_connections
94
+ WHERE requester_github_id = ${param}::bigint AND status = 'accepted'
95
+ UNION
96
+ SELECT requester_github_id AS gid FROM platform_identity_connections
97
+ WHERE addressee_github_id = ${param}::bigint AND status = 'accepted'`;
98
+ }
99
+
100
+ // The same expansion from a SET of accounts (the 2-hop frontier). `cte` is the
101
+ // name of a CTE with one `gid` column.
102
+ function frontierSql(cte) {
103
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(cte))) {
104
+ throw new Error('frontierSql: cte must be a bare SQL identifier');
105
+ }
106
+ return `
107
+ SELECT c.addressee_github_id AS gid
108
+ FROM platform_identity_connections c
109
+ JOIN ${cte} s ON c.requester_github_id = s.gid
110
+ WHERE c.status = 'accepted'
111
+ UNION
112
+ SELECT c.requester_github_id AS gid
113
+ FROM platform_identity_connections c
114
+ JOIN ${cte} s ON c.addressee_github_id = s.gid
115
+ WHERE c.status = 'accepted'`;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Addressing: handle → account
120
+ // ---------------------------------------------------------------------------
121
+
122
+ // Resolve a handle to the row the visibility predicates need. Matches the
123
+ // current handle first, then the single-slot previous_handle (P01's rename
124
+ // window) — an ACTION endpoint follows a rename rather than answering 301, so a
125
+ // stale "Connect" link on a page still works. Returns null for an unknown
126
+ // handle; the CALLER decides visibility, because the answer is viewer-relative.
127
+ async function findAccountByHandle(handle, { pool = defaultPool } = {}) {
128
+ const norm = String(handle ?? '').trim().toLowerCase();
129
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(norm)) return null;
130
+ const { rows } = await pool.query(
131
+ `SELECT github_id, handle, display_name, avatar_url, profile_state, account_visibility
132
+ FROM platform_identity_accounts
133
+ WHERE lower(handle) = $1 OR lower(previous_handle) = $1
134
+ ORDER BY (lower(handle) = $1) DESC
135
+ LIMIT 1`,
136
+ [norm],
137
+ );
138
+ return rows[0] || null;
139
+ }
140
+
141
+ // Is this account already an ACCEPTED connection of the viewer? The other half
142
+ // of viewer-relative visibility: a private account you are connected to is
143
+ // addressable to YOU (ADR 0202 D4 §2) and to nobody else.
144
+ async function isConnectedTo(viewerGithubId, targetGithubId, { pool = defaultPool } = {}) {
145
+ const { rowCount } = await pool.query(
146
+ `SELECT 1 FROM platform_identity_connections
147
+ WHERE status = 'accepted'
148
+ AND pair_lo = LEAST($1::bigint, $2::bigint)
149
+ AND pair_hi = GREATEST($1::bigint, $2::bigint)
150
+ LIMIT 1`,
151
+ [viewerGithubId, targetGithubId],
152
+ );
153
+ return rowCount > 0;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Write: request
158
+ // ---------------------------------------------------------------------------
159
+
160
+ // Send a connection request. ONE statement, and the shape is load-bearing:
161
+ //
162
+ // `expired` resolves any timed-out pending row of this pair — referenced by
163
+ // the main query through `guard` so the executor must drain it
164
+ // BEFORE the INSERT is attempted (an UNREFERENCED data-modifying
165
+ // CTE runs in ExecPostprocessPlan, i.e. AFTER the insert, which
166
+ // would hit the very unique index the expiry exists to release);
167
+ // the cooldown NOT EXISTS inserts zero rows inside the 90-day window, which is
168
+ // how a swallowed re-ask writes nothing while still answering yes.
169
+ //
170
+ // Returns { outcome: 'created' | 'swallowed' | 'already_open' }. The ROUTE must
171
+ // answer 'created' and 'swallowed' identically — that is the whole point.
172
+ //
173
+ // WHAT THE SILENCE DOES NOT COVER, stated rather than implied: a swallowed ask
174
+ // writes nothing, so it never appears in the requester's own outbox, while a real
175
+ // one does within milliseconds. The RESPONSE reveals nothing (D2 §2); the absence
176
+ // afterwards is observable. ADR 0202's own Consequences accept exactly this — "a
177
+ // builder can send the same request twice a year and never learn why nothing
178
+ // happens" — and the alternative, writing a decoy row, would be a lie in the
179
+ // database to protect a lie in the API. See ADR 0207.
180
+ async function requestConnection(
181
+ { requesterGithubId, addresseeGithubId, note = null },
182
+ { pool = defaultPool } = {},
183
+ ) {
184
+ const sql = `
185
+ WITH expired AS (
186
+ UPDATE platform_identity_connections c
187
+ SET status = 'expired', resolved_at = now(), updated_at = now()
188
+ WHERE c.pair_lo = LEAST($1::bigint, $2::bigint)
189
+ AND c.pair_hi = GREATEST($1::bigint, $2::bigint)
190
+ AND c.status = 'pending'
191
+ AND c.created_at <= now() - make_interval(days => $3::int)
192
+ RETURNING c.id
193
+ ),
194
+ guard AS (SELECT count(*)::int AS expired_count FROM expired)
195
+ INSERT INTO platform_identity_connections (requester_github_id, addressee_github_id, note)
196
+ SELECT $1::bigint, $2::bigint, $5::text
197
+ FROM guard
198
+ WHERE NOT EXISTS (
199
+ SELECT 1 FROM platform_identity_connections c
200
+ WHERE c.pair_lo = LEAST($1::bigint, $2::bigint)
201
+ AND c.pair_hi = GREATEST($1::bigint, $2::bigint)
202
+ AND c.status = 'rejected'
203
+ AND c.requester_github_id = $1::bigint
204
+ AND c.resolved_at > now() - make_interval(days => $4::int)
205
+ )
206
+ RETURNING id`;
207
+ try {
208
+ const { rowCount, rows } = await pool.query(sql, [
209
+ requesterGithubId, addresseeGithubId,
210
+ REQUEST_EXPIRY_DAYS, DECLINE_COOLDOWN_DAYS, note,
211
+ ]);
212
+ return rowCount > 0
213
+ ? { outcome: 'created', id: String(rows[0].id) }
214
+ : { outcome: 'swallowed' };
215
+ } catch (err) {
216
+ // 23505 = the partial unique index over (pending, accepted): a request or a
217
+ // connection is already open for this pair. Safe to name — both cases are
218
+ // already visible in the caller's OWN scope, so it discloses nothing the
219
+ // caller cannot read anyway.
220
+ if (err && err.code === '23505') return { outcome: 'already_open' };
221
+ throw err;
222
+ }
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Write: resolve — accept, decline, remove/cancel
227
+ // ---------------------------------------------------------------------------
228
+
229
+ // Accept a pending request. The addressee ONLY, in one statement, so an
230
+ // 'accepted' row can never exist without being readable as a connection.
231
+ // rowCount 0 covers every refusal — not yours, not pending, expired — and the
232
+ // route answers all of them 404, so a connection id cannot be probed.
233
+ async function acceptConnection({ id, githubId }, { pool = defaultPool } = {}) {
234
+ const { rowCount } = await pool.query(
235
+ `UPDATE platform_identity_connections
236
+ SET status = 'accepted', resolved_at = now(), updated_at = now()
237
+ WHERE id = $1 AND addressee_github_id = $2::bigint AND status = 'pending'
238
+ AND created_at > now() - make_interval(days => $3::int)
239
+ RETURNING id`,
240
+ [id, githubId, REQUEST_EXPIRY_DAYS],
241
+ );
242
+ return rowCount > 0;
243
+ }
244
+
245
+ // Decline. The addressee ONLY. The requester is never told (D2 §1); the row is
246
+ // kept because 'rejected' IS the 90-day cooldown, not merely an audit trail.
247
+ async function declineConnection({ id, githubId }, { pool = defaultPool } = {}) {
248
+ const { rowCount } = await pool.query(
249
+ `UPDATE platform_identity_connections
250
+ SET status = 'rejected', resolved_at = now(), updated_at = now()
251
+ WHERE id = $1 AND addressee_github_id = $2::bigint AND status = 'pending'
252
+ AND created_at > now() - make_interval(days => $3::int)
253
+ RETURNING id`,
254
+ [id, githubId, REQUEST_EXPIRY_DAYS],
255
+ );
256
+ return rowCount > 0;
257
+ }
258
+
259
+ // Remove an accepted connection (either side — it is consensual data about two
260
+ // people, so either may withdraw) or cancel a pending one (the requester only;
261
+ // an addressee ending a pending row is a DECLINE and must go through that path,
262
+ // which is what arms the cooldown). One statement, two mutually-exclusive
263
+ // branches keyed on status. Remove HARD-DELETES: a tombstone saying "A removed
264
+ // B" is data neither consented to keep and no read path uses.
265
+ // Returns 'removed' | 'cancelled' | null (nothing matched → the route 404s).
266
+ async function removeConnection({ id, githubId }, { pool = defaultPool } = {}) {
267
+ const { rows } = await pool.query(
268
+ `WITH removed AS (
269
+ DELETE FROM platform_identity_connections
270
+ WHERE id = $1 AND status = 'accepted'
271
+ AND (requester_github_id = $2::bigint OR addressee_github_id = $2::bigint)
272
+ RETURNING 'removed'::text AS outcome
273
+ ),
274
+ cancelled AS (
275
+ UPDATE platform_identity_connections
276
+ SET status = 'cancelled', resolved_at = now(), updated_at = now()
277
+ WHERE id = $1 AND status = 'pending' AND requester_github_id = $2::bigint
278
+ RETURNING 'cancelled'::text AS outcome
279
+ )
280
+ SELECT outcome FROM removed UNION ALL SELECT outcome FROM cancelled`,
281
+ [id, githubId],
282
+ );
283
+ return rows[0] ? rows[0].outcome : null;
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Read: the two own-scope lists
288
+ // ---------------------------------------------------------------------------
289
+
290
+ // The caller's accepted connections. OWN SCOPE: the floor is profile_state
291
+ // alone, because a private account that connected with you appears in your list
292
+ // and in theirs (D4 §2) — and in no public list, which is why this read has no
293
+ // public twin. The returned `count` is the array's own length by construction
294
+ // (the leak-by-subtraction rule); there is no hidden remainder.
295
+ async function listConnections({ githubId, limit = FANOUT_1, offset = 0 }, { pool = defaultPool } = {}) {
296
+ const { rows } = await pool.query(
297
+ `WITH edge AS (
298
+ SELECT id, addressee_github_id AS gid, resolved_at
299
+ FROM platform_identity_connections
300
+ WHERE requester_github_id = $1::bigint AND status = 'accepted'
301
+ UNION ALL
302
+ SELECT id, requester_github_id AS gid, resolved_at
303
+ FROM platform_identity_connections
304
+ WHERE addressee_github_id = $1::bigint AND status = 'accepted'
305
+ )
306
+ SELECT e.id::text AS id, e.resolved_at AS connected_at, ${COUNTERPARTY_COLUMNS}
307
+ FROM edge e
308
+ JOIN platform_identity_accounts pa ON pa.github_id = e.gid
309
+ WHERE ${accountActiveSql('pa')}
310
+ ORDER BY e.resolved_at DESC NULLS LAST, e.id DESC
311
+ LIMIT $2 OFFSET $3`,
312
+ [githubId, Math.min(Number(limit) || FANOUT_1, FANOUT_1), Math.max(0, Number(offset) || 0)],
313
+ );
314
+ return rows;
315
+ }
316
+
317
+ // The caller's inbox and outbox: pending, unexpired requests in both directions.
318
+ // The counterparty floors on profile_state only — an incoming request must name
319
+ // who is asking or it cannot be decided, and D4 §2 lets a private account send.
320
+ //
321
+ // Each direction carries its OWN limit: a shared cap applied before the split
322
+ // would let a busy outbox push every incoming request out of the response, and
323
+ // an empty inbox that is really a truncation is the worst kind of wrong answer.
324
+ //
325
+ // `seen_at` rides the INCOMING rows only and is stripped from the outbox — a
326
+ // decline is silent, and so is a read (D2 §1): a requester who could watch
327
+ // seen_at appear would learn the addressee is deliberating. The stamp is then
328
+ // written back for exactly the rows this call rendered, so it means "delivered",
329
+ // not "your inbox was polled".
330
+ async function listRequests({ githubId, limit = FANOUT_1 }, { pool = defaultPool } = {}) {
331
+ const cap = Math.min(Number(limit) || FANOUT_1, FANOUT_1);
332
+ const { rows } = await pool.query(
333
+ `WITH mine AS (
334
+ (SELECT c.id, c.note, c.created_at, c.seen_at, true AS incoming,
335
+ c.requester_github_id AS other
336
+ FROM platform_identity_connections c
337
+ WHERE c.addressee_github_id = $1::bigint AND c.status = 'pending'
338
+ AND c.created_at > now() - make_interval(days => $2::int)
339
+ ORDER BY c.created_at DESC, c.id DESC
340
+ LIMIT $3)
341
+ UNION ALL
342
+ (SELECT c.id, c.note, c.created_at, c.seen_at, false AS incoming,
343
+ c.addressee_github_id AS other
344
+ FROM platform_identity_connections c
345
+ WHERE c.requester_github_id = $1::bigint AND c.status = 'pending'
346
+ AND c.created_at > now() - make_interval(days => $2::int)
347
+ ORDER BY c.created_at DESC, c.id DESC
348
+ LIMIT $3)
349
+ )
350
+ SELECT m.id::text AS id, m.note, m.created_at, m.seen_at, m.incoming, ${COUNTERPARTY_COLUMNS}
351
+ FROM mine m
352
+ JOIN platform_identity_accounts pa ON pa.github_id = m.other
353
+ WHERE ${accountActiveSql('pa')}
354
+ ORDER BY m.created_at DESC, m.id DESC`,
355
+ [githubId, REQUEST_EXPIRY_DAYS, cap],
356
+ );
357
+ const incoming = rows.filter((r) => r.incoming).map(({ incoming, ...r }) => r);
358
+ const outgoing = rows.filter((r) => !r.incoming).map(({ incoming, seen_at, ...r }) => r);
359
+ const unseen = incoming.filter((r) => !r.seen_at).map((r) => r.id);
360
+ if (unseen.length) {
361
+ await pool.query(
362
+ `UPDATE platform_identity_connections
363
+ SET seen_at = now(), updated_at = now()
364
+ WHERE id = ANY($1::bigint[]) AND addressee_github_id = $2::bigint AND seen_at IS NULL`,
365
+ [unseen, githubId],
366
+ );
367
+ }
368
+ return { incoming, outgoing };
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Read: the bounded degree label
373
+ // ---------------------------------------------------------------------------
374
+
375
+ // degreeBetween(viewer, target) → { degree: 0|1|2|3|null, mutuals: [], truncated }
376
+ //
377
+ // Bounded expansion FROM THE VIEWER, staged exactly as ADR 0202 D3 describes,
378
+ // because the stages have different costs and most calls stop at the first:
379
+ //
380
+ // near — one statement: is the target in V's 1-hop (1st), and what is the
381
+ // intersection of V's and T's 1-hops (2nd, and the mutual list for
382
+ // free). Two index range scans and a hash intersect.
383
+ // far — only when `near` found nothing: V's 1-hop (cap FANOUT_1) expanded to
384
+ // a 2-hop frontier (cap FANOUT_2) and intersected with T's 1-hop.
385
+ //
386
+ // Deliberate departure from the ADR's "recursive CTE" wording, same semantics:
387
+ // a recursive term cannot carry a PER-LEVEL fan-out cap or a deterministic
388
+ // per-level truncation order, which are the two properties D3 actually decided.
389
+ // Level-wise CTEs express both; `ORDER BY gid LIMIT n` makes a truncation
390
+ // reproducible rather than a different answer each call.
391
+ //
392
+ // Past the caps or past DEGREE_MAX the answer is null — "out of network", never
393
+ // a number the query did not establish. `truncated` says a cap bit, so the UI
394
+ // can be honest about it; it describes the VIEWER'S OWN graph size and so
395
+ // discloses nothing about anyone else.
396
+ //
397
+ // TWO HONEST LIMITS (ADR 0207). (a) The TARGET's 1-hop is capped too, and that
398
+ // truncation is deliberately NOT reported: `truncated: true` on it would leak one
399
+ // bit about a third party's degree, which is a worse trade than an occasional
400
+ // null. (b) The QUERY COUNT is constant, and so is the OUTPUT size — but the
401
+ // frontier's WORK is O(sum of degree over v1), because ORDER BY … LIMIT must
402
+ // materialise the frontier before it truncates. At hub scale that is a few index
403
+ // scans; the revisit trigger is a measured p95, not a guess.
404
+ async function degreeBetween(viewerGithubId, targetGithubId, { pool = defaultPool } = {}) {
405
+ if (String(viewerGithubId) === String(targetGithubId)) {
406
+ return { degree: 0, mutuals: [], truncated: false };
407
+ }
408
+
409
+ const near = await pool.query(
410
+ `WITH v1 AS (
411
+ SELECT r.gid FROM (${neighboursSql('$1')}) r
412
+ JOIN platform_identity_accounts pa ON pa.github_id = r.gid
413
+ WHERE ${accountActiveSql('pa')}
414
+ ORDER BY r.gid LIMIT $3
415
+ ),
416
+ t1 AS (
417
+ SELECT r.gid FROM (${neighboursSql('$2')}) r ORDER BY r.gid LIMIT $3
418
+ )
419
+ SELECT
420
+ EXISTS (SELECT 1 FROM platform_identity_connections
421
+ WHERE status = 'accepted'
422
+ AND pair_lo = LEAST($1::bigint, $2::bigint)
423
+ AND pair_hi = GREATEST($1::bigint, $2::bigint)) AS is_first,
424
+ (SELECT count(*)::int FROM v1) AS v1_count,
425
+ COALESCE((
426
+ SELECT json_agg(json_build_object(
427
+ 'handle', pa.handle,
428
+ 'display_name', pa.display_name,
429
+ 'avatar_url', pa.avatar_url) ORDER BY pa.github_id)
430
+ FROM v1
431
+ JOIN t1 ON t1.gid = v1.gid
432
+ JOIN platform_identity_accounts pa ON pa.github_id = v1.gid
433
+ ), '[]'::json) AS mutuals`,
434
+ [viewerGithubId, targetGithubId, FANOUT_1],
435
+ );
436
+ const n = near.rows[0] || {};
437
+ // Every mutual is one of V's OWN connections, so V is entitled to all of them
438
+ // and the array is complete up to FANOUT_1 — which is why the count may be its
439
+ // length rather than a separate number.
440
+ const mutuals = Array.isArray(n.mutuals) ? n.mutuals : [];
441
+ const v1Truncated = (n.v1_count || 0) >= FANOUT_1;
442
+
443
+ if (n.is_first) return { degree: 1, mutuals, truncated: v1Truncated };
444
+ if (mutuals.length > 0) return { degree: 2, mutuals, truncated: v1Truncated };
445
+
446
+ const far = await pool.query(
447
+ `WITH v1 AS (
448
+ SELECT r.gid FROM (${neighboursSql('$1')}) r
449
+ JOIN platform_identity_accounts pa ON pa.github_id = r.gid
450
+ WHERE ${accountActiveSql('pa')}
451
+ ORDER BY r.gid LIMIT $3
452
+ ),
453
+ v2 AS (
454
+ SELECT f.gid FROM (${frontierSql('v1')}) f
455
+ JOIN platform_identity_accounts pa ON pa.github_id = f.gid
456
+ WHERE ${accountVisibleSql('pa')}
457
+ AND f.gid <> $1::bigint
458
+ AND f.gid NOT IN (SELECT gid FROM v1)
459
+ ORDER BY f.gid LIMIT $4
460
+ ),
461
+ t1 AS (
462
+ SELECT r.gid FROM (${neighboursSql('$2')}) r ORDER BY r.gid LIMIT $3
463
+ )
464
+ SELECT EXISTS (SELECT 1 FROM v2 JOIN t1 ON t1.gid = v2.gid) AS is_third,
465
+ (SELECT count(*)::int FROM v2) AS v2_count`,
466
+ [viewerGithubId, targetGithubId, FANOUT_1, FANOUT_2],
467
+ );
468
+ const f = far.rows[0] || {};
469
+ const truncated = v1Truncated || (f.v2_count || 0) >= FANOUT_2;
470
+ return { degree: f.is_third ? DEGREE_MAX : null, mutuals, truncated };
471
+ }
472
+
473
+ module.exports = {
474
+ CONNECTION_STATUSES,
475
+ REQUEST_EXPIRY_DAYS,
476
+ DECLINE_COOLDOWN_DAYS,
477
+ NOTE_MAX_CHARS,
478
+ DEGREE_MAX,
479
+ FANOUT_1,
480
+ FANOUT_2,
481
+ findAccountByHandle,
482
+ isConnectedTo,
483
+ requestConnection,
484
+ acceptConnection,
485
+ declineConnection,
486
+ removeConnection,
487
+ listConnections,
488
+ listRequests,
489
+ degreeBetween,
490
+ };
@@ -0,0 +1,78 @@
1
+ -- platform_identity_015_connections.sql — the connection graph (task 1002295 /
2
+ -- goal 1000045 criterion C6; ADR 0202 D1, whose migration sketch this is).
3
+ --
4
+ -- WHAT THIS IS. One directed row per relationship — the REQUEST, not the edge.
5
+ -- requester/addressee are kept because the inbox, the cancel path and the
6
+ -- 90-day decline cooldown all need to know who asked; the unordered pair is
7
+ -- GENERATED from LEAST/GREATEST so A→B and B→A collide on one index instead of
8
+ -- both being accepted. Reads are symmetric (the module unions both directions);
9
+ -- no route writes a direction-aware WHERE of its own.
10
+ --
11
+ -- WHY GENERATED + A PARTIAL UNIQUE INDEX rather than a route check: a duplicate
12
+ -- request and a simultaneous reciprocal request then become DATABASE errors, not
13
+ -- a race two handlers have to remember to lose. The index covers only the OPEN
14
+ -- statuses, so resolved rows stay as the audit trail and never block a later ask
15
+ -- (the goal_membership_requests precedent, core_188).
16
+ --
17
+ -- WHY NO TOMBSTONE ON REMOVE (ADR 0202 D2): removing an accepted connection
18
+ -- HARD-DELETES the row. "A removed B" is data neither party consented to keep
19
+ -- and no read path uses. 'rejected' is kept because it still does work — it is
20
+ -- the cooldown — which is exactly why the asymmetry is not an inconsistency.
21
+ --
22
+ -- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank check, admission gate
23
+ -- or credit path reads this table; accepting a connection changes nobody's rank,
24
+ -- credits, or access to any project. It is descriptive social data only.
25
+ --
26
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Forward-safe: CREATE
27
+ -- only, so the previous code release still runs against this schema. Idempotent.
28
+
29
+ BEGIN;
30
+
31
+ CREATE TABLE IF NOT EXISTS platform_identity_connections (
32
+ id bigserial PRIMARY KEY,
33
+ requester_github_id bigint NOT NULL,
34
+ addressee_github_id bigint NOT NULL,
35
+ -- The unordered pair, derived — never written by a caller, so A→B and B→A
36
+ -- collide on the index below instead of both being accepted.
37
+ pair_lo bigint GENERATED ALWAYS AS (LEAST(requester_github_id, addressee_github_id)) STORED,
38
+ pair_hi bigint GENERATED ALWAYS AS (GREATEST(requester_github_id, addressee_github_id)) STORED,
39
+ status text NOT NULL DEFAULT 'pending'
40
+ CHECK (status IN ('pending', 'accepted', 'rejected', 'cancelled', 'expired')),
41
+ -- The note cap lives HERE as well as in the route: requestConnection is an
42
+ -- exported domain function, and every other invariant in this table is one the
43
+ -- engine refuses to break rather than one a caller must remember.
44
+ note text CONSTRAINT platform_identity_connections_note_len
45
+ CHECK (note IS NULL OR length(note) <= 280),
46
+ seen_at timestamptz,
47
+ created_at timestamptz NOT NULL DEFAULT now(),
48
+ resolved_at timestamptz,
49
+ updated_at timestamptz NOT NULL DEFAULT now(),
50
+ CONSTRAINT platform_identity_connections_not_self
51
+ CHECK (requester_github_id <> addressee_github_id)
52
+ );
53
+
54
+ -- At most ONE open relationship per pair. Resolved rows stay as the audit trail
55
+ -- and never block a later ask.
56
+ CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_connections_open
57
+ ON platform_identity_connections (pair_lo, pair_hi)
58
+ WHERE status IN ('pending', 'accepted');
59
+
60
+ -- The three reads: my inbox, my outbox, and the 1-hop neighbourhood both the
61
+ -- degree traversal and the suggester expand from.
62
+ CREATE INDEX IF NOT EXISTS platform_identity_connections_addressee
63
+ ON platform_identity_connections (addressee_github_id, status);
64
+ CREATE INDEX IF NOT EXISTS platform_identity_connections_requester
65
+ ON platform_identity_connections (requester_github_id, status);
66
+ CREATE INDEX IF NOT EXISTS platform_identity_connections_pair_accepted
67
+ ON platform_identity_connections (pair_lo, pair_hi) WHERE status = 'accepted';
68
+
69
+ COMMENT ON TABLE platform_identity_connections IS
70
+ 'Account-to-account connections on the hub (ADR 0202). One DIRECTED request row per relationship; pair_lo/pair_hi are the generated unordered key a partial unique index over (pending, accepted) enforces. Lifecycle pending -> accepted | rejected | cancelled | expired; remove hard-deletes. Descriptive only — never authority (ADR 0016).';
71
+
72
+ COMMENT ON COLUMN platform_identity_connections.status IS
73
+ 'pending -> accepted (addressee accepted) | rejected (addressee declined — SILENT to the requester, and the 90-day cooldown that swallows a re-ask) | cancelled (requester withdrew) | expired (30 days unanswered, resolved at write time because now() cannot live in an index predicate).';
74
+
75
+ COMMENT ON COLUMN platform_identity_connections.seen_at IS
76
+ 'Stamped on first inbox render (ADR 0202 D2, the privacy-spec D4 parity item) so a delivered/seen affordance can land later without a schema change. Never exposed to the requester — a decline is silent, and so is a read.';
77
+
78
+ COMMIT;
@@ -10,7 +10,7 @@
10
10
  "maintainer": "Cloud Bongos maintainers",
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
- "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile"],
13
+ "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections"],
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep"]
16
16
  },