@bongos/core 1.19.1018 → 1.19.1020

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.
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 2037,
7
- "unique_ids": 1738,
8
- "certain": 2037,
6
+ "strings": 2038,
7
+ "unique_ids": 1739,
8
+ "certain": 2038,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 212
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 102,
16
- "strings": 1546
16
+ "strings": 1547
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -1481,8 +1481,8 @@
1481
1481
  "text": "Request sent",
1482
1482
  "occurrences": 2,
1483
1483
  "at": [
1484
- "modules/hall-ui/public/profile-connections.js:86",
1485
- "modules/hall-ui/public/profile-connections.js:134"
1484
+ "modules/hall-ui/public/profile-connections.js:95",
1485
+ "modules/hall-ui/public/profile-connections.js:143"
1486
1486
  ]
1487
1487
  },
1488
1488
  {
@@ -3471,6 +3471,15 @@
3471
3471
  "origin": "html-text",
3472
3472
  "confidence": "certain"
3473
3473
  },
3474
+ {
3475
+ "id": "306bc316184d",
3476
+ "surface": "builders-hall",
3477
+ "text": "Builders you may know",
3478
+ "file": "modules/hall-ui/public/profile-connections.js",
3479
+ "line": 158,
3480
+ "origin": "js-markup",
3481
+ "confidence": "certain"
3482
+ },
3474
3483
  {
3475
3484
  "id": "60b248791dc3",
3476
3485
  "surface": "builders-hall",
@@ -9542,7 +9551,7 @@
9542
9551
  "surface": "builders-hall",
9543
9552
  "text": "Request sent",
9544
9553
  "file": "modules/hall-ui/public/profile-connections.js",
9545
- "line": 86,
9554
+ "line": 95,
9546
9555
  "origin": "js-markup",
9547
9556
  "confidence": "certain"
9548
9557
  },
@@ -9551,7 +9560,7 @@
9551
9560
  "surface": "builders-hall",
9552
9561
  "text": "Request sent",
9553
9562
  "file": "modules/hall-ui/public/profile-connections.js",
9554
- "line": 134,
9563
+ "line": 143,
9555
9564
  "origin": "js-markup",
9556
9565
  "confidence": "certain"
9557
9566
  },
@@ -10280,7 +10289,7 @@
10280
10289
  "surface": "builders-hall",
10281
10290
  "text": "Sign in to the platform once to activate your profile before connecting.",
10282
10291
  "file": "modules/hall-ui/public/profile-connections.js",
10283
- "line": 220,
10292
+ "line": 253,
10284
10293
  "origin": "js-markup",
10285
10294
  "confidence": "certain"
10286
10295
  },
@@ -10442,7 +10451,7 @@
10442
10451
  "surface": "builders-hall",
10443
10452
  "text": "Something went wrong. Try again.",
10444
10453
  "file": "modules/hall-ui/public/profile-connections.js",
10445
- "line": 275,
10454
+ "line": 319,
10446
10455
  "origin": "js-notice",
10447
10456
  "confidence": "certain"
10448
10457
  },
@@ -10856,7 +10865,7 @@
10856
10865
  "surface": "builders-hall",
10857
10866
  "text": "That request is no longer available.",
10858
10867
  "file": "modules/hall-ui/public/profile-connections.js",
10859
- "line": 270,
10868
+ "line": 314,
10860
10869
  "origin": "js-notice",
10861
10870
  "confidence": "certain"
10862
10871
  },
@@ -11567,7 +11576,7 @@
11567
11576
  "surface": "builders-hall",
11568
11577
  "text": "They have asked to connect.",
11569
11578
  "file": "modules/hall-ui/public/profile-connections.js",
11570
- "line": 203,
11579
+ "line": 236,
11571
11580
  "origin": "js-markup",
11572
11581
  "confidence": "certain"
11573
11582
  },
@@ -15320,7 +15329,7 @@
15320
15329
  "surface": "builders-hall",
15321
15330
  "text": "{…} asking to connect",
15322
15331
  "file": "modules/hall-ui/public/profile-connections.js",
15323
- "line": 136,
15332
+ "line": 145,
15324
15333
  "origin": "js-markup",
15325
15334
  "confidence": "certain"
15326
15335
  },
@@ -15581,7 +15590,7 @@
15581
15590
  "surface": "builders-hall",
15582
15591
  "text": "{…} you have sent",
15583
15592
  "file": "modules/hall-ui/public/profile-connections.js",
15584
- "line": 137,
15593
+ "line": 146,
15585
15594
  "origin": "js-markup",
15586
15595
  "confidence": "certain"
15587
15596
  },
@@ -2515,5 +2515,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2515
2515
  only a token and a login — the <redacted> precedent.
2516
2516
  1.19.1018 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2517
2517
  landed since 1.19.1017 with no explicit bump. run 36083406390. (task 1002620)
2518
+ 1.19.1019 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2519
+ landed since 1.19.1018 with no explicit bump. run 36084684814. (task 1002620)
2520
+ 1.19.1020 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2521
+ landed since 1.19.1019 with no explicit bump. run 36085437258. (task 1002620)
2518
2522
  ---------------------------------------------------------------------------
2519
2523
  ```
@@ -226,6 +226,7 @@ A module calls `api.seams.emit(eventName, payload)` to broadcast. Any module tha
226
226
  | Port: `builder-settings` | `builder-settings` (R80) | core `routes/me.js` (`GET /me`) + `cascade-dispatch.js` | `{ getBuilderBehaviorPrefs, describeWandering, describeRender, computeNeeds, skillCeiling }` — the per-builder prefs read surface. The `GET /me` aggregator resolves it for its wandering/render/needs slices; the dormant cascade router for the skill ceiling. Default-on; degrades gracefully when off ([ADR 0093](adr/0093-tranche-2-core-carve-sequence.md) §1, [02-module-map §2](design/modular-architecture/02-module-map.md)). |
227
227
  | Port: `onboarding` | `onboarding` (R81) | the lifecycle (`db.js` claim/ship — transaction-participant `markStage`) + `auth.js` + `routes/me.js`/`builders.js`/`tasks.js`/`claims.js` | `{ getState, shapeForApi, safeMarkStage, markStage, broadcastGraduation, restockForClaim, restockAll, SOURCE_TAG }` — the onboarding state-machine + newcomer-restock surface. The FIRST core→module port the lifecycle ticks inside its own txn (alongside `reward`). Default-on; degrades to best-effort no-ops when off ([ADR 0093](adr/0093-tranche-2-core-carve-sequence.md) §1). |
228
228
  | Event: `builder.box-reconcile-needed` | core `db.js` (on rank change / deactivate / auto-graduation) | `dev-box` | `{ builderId, rank, status, actor }` — flags the box for reconciliation. |
229
+ | Event: `builder.secrets-scrub-needed` | core `db.js` (`deactivateBuilder`) | `art-pipeline` (when installed) | `{ builderId, actor }` — task 1003338 / [ADR 0332](adr/0332-offboarding-secret-scrub-semantics.md): best-effort teardown port for a MODULE-owned builder-keyed secret (today: the Gemini BYOK key, `gemini_key_enc`/`last4`/`set_at`). Core does not write those columns directly (ADR 0102) — the module listens and clears its own store. `github_oauth_tokens` (core-owned) is deleted synchronously inside `deactivateBuilder`'s transaction instead, no event needed. |
229
230
 
230
231
  ---
231
232
 
@@ -40,6 +40,15 @@
40
40
  // viewer was actually handed — never a total carried beside a filtered list,
41
41
  // which is a leak by subtraction (ADR 0171 D4 / ADR 0202 D4).
42
42
  //
43
+ // "BUILDERS YOU MAY KNOW" (R21, task 1002297; ADR 0202 D5) renders on YOUR
44
+ // OWN profile only, under your connections — never on someone else's, which
45
+ // is what makes it own-scope: the candidate pool is the session's own
46
+ // frontier. The server already picked the reason; this file renders it
47
+ // verbatim and adds no ranking or filtering of its own. The read is
48
+ // ADDITIVE, never load-bearing — a failed or absent read (an older server,
49
+ // a provisional account) simply omits the section rather than hiding the
50
+ // connections + requests the rest of the own-profile view already has.
51
+ //
43
52
  // NOTHING HERE IS AUTHORITY (ADR 0016): a connection grants no rank, no credits
44
53
  // and no admission. Nothing rendered here is rollup-derived either, which is
45
54
  // why `hide_stats` does not suppress it — hiding your stats hides the numbers
@@ -137,21 +146,45 @@
137
146
  + (outgoing.length ? `<h3 class="conn-sub">${escapeHtml(plural(outgoing.length, 'request'))} you have sent</h3><ul class="conn-reqs">${outbox}</ul>` : '');
138
147
  }
139
148
 
140
- // Your own connections + both request queues. Returns false when the hub
141
- // routes are absent or the session is gone, so the caller leaves the section
142
- // hidden rather than rendering an empty shell.
149
+ // "Builders you may know" (R21, task 1002297; ADR 0202 D5) — mutual
150
+ // connections + shared projects, each explained in one line by the server.
151
+ // Empty when the server has nothing to say, same as every other honest
152
+ // empty here (no section is better than an empty shell).
153
+ function suggestionsHtml(suggestions) {
154
+ if (!suggestions.length) return '';
155
+ const rows = suggestions.map((s) => `<li class="conn-req">${personHtml(s)}`
156
+ + `<p class="conn-req__note">${escapeHtml(s.reason)}</p>`
157
+ + `<span class="conn-req__acts">${suggestConnectHtml(s.handle)}</span></li>`).join('');
158
+ return `<h3 class="conn-sub">Builders you may know</h3><ul class="conn-reqs">${rows}</ul>`;
159
+ }
160
+
161
+ // A suggestion's own connect button, `data-act="suggest-connect"` rather
162
+ // than the generic `btn()` helper: the degree-view connect button lives
163
+ // beside a single `.conn-act` paragraph the click handler replaces by a
164
+ // page-wide match, and a second one in this list would collide with it.
165
+ function suggestConnectHtml(handle) {
166
+ return `<button class="btn-ghost" type="button" data-act="suggest-connect" data-handle="${escapeHtml(handle)}">Connect</button>`;
167
+ }
168
+
169
+ // Your own connections + both request queues + "builders you may know".
170
+ // Returns false when the hub routes are absent or the session is gone, so
171
+ // the caller leaves the section hidden rather than rendering an empty
172
+ // shell.
143
173
  async function renderOwn(body) {
144
- const [list, reqs] = await Promise.all([
174
+ const [list, reqs, suggestions] = await Promise.all([
145
175
  api.request('GET', `${API}/connections`),
146
176
  api.request('GET', `${API}/connections/requests`),
177
+ api.request('GET', `${API}/connections/suggestions`),
147
178
  ]);
148
179
  if (!list.ok || !reqs.ok) return false;
149
180
  const connections = arr(list.data && list.data.connections);
150
181
  const incoming = arr(reqs.data && reqs.data.incoming);
151
182
  const outgoing = arr(reqs.data && reqs.data.outgoing);
183
+ const suggested = suggestions.ok ? arr(suggestions.data && suggestions.data.suggestions) : [];
152
184
  body.innerHTML = requestsHtml(incoming, outgoing)
153
185
  + `<p class="scroll__lede">${escapeHtml(plural(connections.length, 'connection'))}.</p>`
154
- + '<div id="conn-ledger"></div>';
186
+ + '<div id="conn-ledger"></div>'
187
+ + suggestionsHtml(suggested);
155
188
  const mount = $('#conn-ledger');
156
189
  if (mount) {
157
190
  const pager = window.OTBKit.makePager('conn', 'profile.connections.perPage');
@@ -264,6 +297,17 @@
264
297
  toast(out.message);
265
298
  return;
266
299
  }
300
+ if (act === 'suggest-connect') {
301
+ const out = await connect(el.dataset.handle);
302
+ toast(out.message);
303
+ // A full re-render, the ROW_ACTIONS precedent below: on success the
304
+ // server's own excludedSuggestionIds now covers this handle, so the
305
+ // refreshed list is the honest way to remove it — a targeted DOM
306
+ // patch would still show it as "sent" for a person the API may
307
+ // already be treating as already-open.
308
+ if (last) await render(last.data, last.selfId);
309
+ return;
310
+ }
267
311
  const spec = ROW_ACTIONS[act];
268
312
  if (!spec) return;
269
313
  const res = await spec.call(el.dataset.id);
@@ -0,0 +1,219 @@
1
+ // modules/platform-identity/connection-suggestions.js — "Builders you may
2
+ // know" (R21, task 1002297, goal 1000045 criterion C6; ADR 0202 D5, which
3
+ // this implements).
4
+ //
5
+ // THREE NATIVE SIGNALS (D5's table), scored and combined, never a fourth:
6
+ // mutual connections (connections.mutualFrontier, weight 3 × min(mutuals,
7
+ // 10)), shared projects (co-membership on platform_identity_project_-
8
+ // memberships, weight 2 × shared_projects), and recency (max(last_active)
9
+ // inside the last 30 days, weight 1). Ties break on last_active, then
10
+ // github_id, so the list is stable (D5) — a hand-tuned linear sum on
11
+ // purpose, since the requirement is a sentence a human reads, not accuracy.
12
+ //
13
+ // GUILDS ARE A RESERVED SLOT, NOT A SIGNAL (D5's third refusal). This file
14
+ // adds no guild term, no guild query and no guild weight — the scorer below
15
+ // reads only the two signals it has real tables for, and leaves guilds for
16
+ // C7's own wave to add a term for once its tables exist (task 1002300)
17
+ // rather than inventing a signal against a schema nobody has written.
18
+ // Likewise refused, per D5: shared goals (instance-local), Discord, the
19
+ // GitHub follower graph, email, or any address-book import.
20
+ //
21
+ // EVERY CANDIDATE CLEARS accountVisibleSql (D4) — the same floor the whole
22
+ // connections feature uses, so a private or provisional account is never
23
+ // suggested: a suggestion the viewer cannot address would both dead-end the
24
+ // Connect button and name a hidden builder by implication. Anyone the
25
+ // viewer already has an open or in-cooldown-declined row with is excluded
26
+ // silently (connections.excludedSuggestionIds) — no "N more" counter, which
27
+ // would be leak-by-subtraction with a friendlier label (D5).
28
+ 'use strict';
29
+
30
+ const api = require('../../src/module-api');
31
+ const { accountVisibleSql } = require('./account-visibility');
32
+ const { onMapSql } = require('./projects-feed');
33
+ const conn = require('./connections');
34
+
35
+ const { pool: defaultPool } = api;
36
+
37
+ // D3: "the same expansion, one level, ranked, LIMIT 20."
38
+ const MAX_SUGGESTIONS = 20;
39
+ // Twice the shown count, so exclusion (pending/cooldown) rarely starves the
40
+ // final list — still a flat, bounded read, never unbounded fan-out.
41
+ const CANDIDATE_POOL_CAP = MAX_SUGGESTIONS * 2;
42
+ // The co-membership scan is bounded from the OTHER side too: a viewer in one
43
+ // very large project must not turn "who else builds here" into an unbounded
44
+ // join. FANOUT_2's order of magnitude (ADR 0202 D3) — generous at today's
45
+ // hub scale, a deliberate cap rather than a guess, same "revisit on a
46
+ // measured p95" posture D3 states for its own caps.
47
+ const MEMBERSHIP_SCAN_CAP = 5000;
48
+ // D5's recency window and weights.
49
+ const RECENT_DAYS = 30;
50
+ const MUTUAL_CAP = 10;
51
+ const WEIGHT_MUTUAL = 3;
52
+ const WEIGHT_SHARED_PROJECT = 2;
53
+ const WEIGHT_RECENCY = 1;
54
+
55
+ // A membership ADR 0205 D4 would let back a public-facing surface: not
56
+ // merely present, but backed by the hub's own sign-in witness — the SQL
57
+ // twin of membershipBacksRollup (platform-identity.js). A row a federated
58
+ // client wrote for someone who never signed in at the hub must not surface
59
+ // their project association to another builder.
60
+ function membershipBackedSql(alias) {
61
+ const q = alias ? `${alias}.` : '';
62
+ return `${q}membership_kind <> 'pending' AND ${q}signin_verified_at IS NOT NULL`;
63
+ }
64
+
65
+ // Builders who share a real, witnessed project membership with the viewer.
66
+ // `named_project` carries a project's name only where its catalogue row is
67
+ // ON THE MAP AND PUBLIC (the getRollupForBuilder naming rule, task
68
+ // 1003558) — otherwise it is null and the caller falls back to D5's generic
69
+ // reason ("a project you both build on"): the aggregate is not the leak,
70
+ // the name is.
71
+ async function sharedProjectCandidates(viewerGithubId, { pool = defaultPool } = {}) {
72
+ const { rows } = await pool.query(
73
+ `WITH mine AS (
74
+ SELECT DISTINCT client_id FROM platform_identity_project_memberships
75
+ WHERE github_id = $1::bigint AND ${membershipBackedSql()}
76
+ ),
77
+ shared AS (
78
+ SELECT m.github_id, m.client_id
79
+ FROM platform_identity_project_memberships m
80
+ JOIN mine ON mine.client_id = m.client_id
81
+ WHERE m.github_id <> $1::bigint AND ${membershipBackedSql('m')}
82
+ ORDER BY m.last_active DESC NULLS LAST, m.github_id ASC
83
+ LIMIT $3
84
+ ),
85
+ named AS (
86
+ SELECT DISTINCT ON (s.github_id) s.github_id, p.name
87
+ FROM shared s
88
+ JOIN platform_identity_sso_clients c ON c.client_id = s.client_id
89
+ JOIN platform_identity_projects p ON p.origin = c.origin
90
+ WHERE ${onMapSql('p')} AND p.visibility = 'public'
91
+ ORDER BY s.github_id, p.name
92
+ )
93
+ SELECT s.github_id::text AS github_id, count(DISTINCT s.client_id)::int AS shared_projects,
94
+ n.name AS named_project
95
+ FROM shared s
96
+ JOIN platform_identity_accounts pa ON pa.github_id = s.github_id
97
+ LEFT JOIN named n ON n.github_id = s.github_id
98
+ WHERE ${accountVisibleSql('pa')}
99
+ GROUP BY s.github_id, n.name
100
+ ORDER BY shared_projects DESC, s.github_id ASC
101
+ LIMIT $2`,
102
+ [viewerGithubId, CANDIDATE_POOL_CAP, MEMBERSHIP_SCAN_CAP],
103
+ );
104
+ return rows;
105
+ }
106
+
107
+ // Every candidate's own most-recent project activity, for D5's recency
108
+ // signal — independent of WHICH signal surfaced the candidate.
109
+ async function lastActiveById(githubIds, { pool = defaultPool } = {}) {
110
+ if (!githubIds.length) return new Map();
111
+ const { rows } = await pool.query(
112
+ `SELECT github_id::text AS github_id, max(last_active) AS last_active
113
+ FROM platform_identity_project_memberships
114
+ WHERE github_id = ANY($1::bigint[]) AND ${membershipBackedSql()}
115
+ GROUP BY github_id`,
116
+ [githubIds],
117
+ );
118
+ return new Map(rows.map((r) => [r.github_id, r.last_active]));
119
+ }
120
+
121
+ // The display identity for a candidate set — handle/display_name/avatar_url,
122
+ // the same whitelist every connections read projects, PLUS hide_stats:
123
+ // never on the wire (stripped below), read only to decide whether this
124
+ // candidate's activity may feed the recency signal. Re-clears
125
+ // accountVisibleSql (defense in depth against a candidate going private
126
+ // between the source query above and this one, inside the same request)
127
+ // rather than trusting the caller's set to still be accurate.
128
+ async function identityById(githubIds, { pool = defaultPool } = {}) {
129
+ if (!githubIds.length) return new Map();
130
+ const { rows } = await pool.query(
131
+ `SELECT pa.github_id::text AS github_id, pa.handle, pa.display_name, pa.avatar_url, pa.hide_stats
132
+ FROM platform_identity_accounts pa
133
+ WHERE pa.github_id = ANY($1::bigint[]) AND ${accountVisibleSql('pa')}`,
134
+ [githubIds],
135
+ );
136
+ return new Map(rows.map((r) => [r.github_id, r]));
137
+ }
138
+
139
+ function mutualReason(n) {
140
+ return `${n} mutual connection${n === 1 ? '' : 's'}`;
141
+ }
142
+
143
+ // listConnectionSuggestions({ githubId, pool }) → [{ handle, display_name,
144
+ // avatar_url, reason }], highest score first, ties broken on last_active
145
+ // then github_id (D5), capped at MAX_SUGGESTIONS. `score` is the RANKING,
146
+ // never the RESPONSE: it is never returned, because an ordinal is exactly
147
+ // the kind of number ADR 0202 D4 §3 suppresses under hide_stats, and
148
+ // handing it over would let a caller reconstruct the recency bit that
149
+ // ranking otherwise only implies.
150
+ async function listConnectionSuggestions({ githubId, pool = defaultPool } = {}) {
151
+ const [mutuals, shared, excluded] = await Promise.all([
152
+ conn.mutualFrontier(githubId, { pool, resultCap: CANDIDATE_POOL_CAP }),
153
+ sharedProjectCandidates(githubId, { pool }),
154
+ conn.excludedSuggestionIds(githubId, { pool }),
155
+ ]);
156
+
157
+ const byId = new Map();
158
+ const get = (id) => {
159
+ if (!byId.has(id)) byId.set(id, { mutual_count: 0, shared_projects: 0, named_project: null });
160
+ return byId.get(id);
161
+ };
162
+ for (const row of mutuals) {
163
+ if (excluded.has(row.github_id)) continue;
164
+ get(row.github_id).mutual_count = row.mutual_count;
165
+ }
166
+ for (const row of shared) {
167
+ if (excluded.has(row.github_id)) continue;
168
+ const c = get(row.github_id);
169
+ c.shared_projects = row.shared_projects;
170
+ c.named_project = row.named_project;
171
+ }
172
+ if (byId.size === 0) return [];
173
+
174
+ const ids = [...byId.keys()];
175
+ const [identities, lastActive] = await Promise.all([
176
+ identityById(ids, { pool }),
177
+ lastActiveById(ids, { pool }),
178
+ ]);
179
+
180
+ const cutoff = Date.now() - RECENT_DAYS * 24 * 60 * 60 * 1000;
181
+ const scored = [];
182
+ for (const id of ids) {
183
+ const person = identities.get(id);
184
+ if (!person) continue; // went private / vanished between reads — never surfaced
185
+ const c = byId.get(id);
186
+ // hide_stats suppresses NUMBERS derived from activity, everywhere else
187
+ // in this module (ADR 0202 D4 §3) — recency is exactly that, so a
188
+ // hidden candidate's real last_active never reaches the score OR the
189
+ // tie-break: both would otherwise let a viewer back out the 30-day bit
190
+ // hide_stats exists to hide.
191
+ const activity = person.hide_stats ? null : (lastActive.get(id) || null);
192
+ const mutualScore = WEIGHT_MUTUAL * Math.min(c.mutual_count, MUTUAL_CAP);
193
+ const projectScore = WEIGHT_SHARED_PROJECT * c.shared_projects;
194
+ const recent = activity ? new Date(activity).getTime() >= cutoff : false;
195
+ const score = mutualScore + projectScore + (recent ? WEIGHT_RECENCY : 0);
196
+ // The reason names the PRIMARY signal — the one that contributed more —
197
+ // rather than every signal that applied, matching D5's "a sentence a
198
+ // human reads", not an itemised ledger.
199
+ const reason = mutualScore >= projectScore && c.mutual_count > 0
200
+ ? mutualReason(c.mutual_count)
201
+ : c.named_project
202
+ ? `You both build on ${c.named_project}`
203
+ : 'A project you both build on';
204
+ scored.push({ id, person, score, reason, activity });
205
+ }
206
+
207
+ scored.sort((a, b) => b.score - a.score
208
+ || new Date(b.activity || 0) - new Date(a.activity || 0)
209
+ || Number(a.id) - Number(b.id));
210
+
211
+ return scored.slice(0, MAX_SUGGESTIONS).map(({ person, reason }) => ({
212
+ handle: person.handle,
213
+ display_name: person.display_name,
214
+ avatar_url: person.avatar_url,
215
+ reason,
216
+ }));
217
+ }
218
+
219
+ module.exports = { listConnectionSuggestions, MAX_SUGGESTIONS };
@@ -471,6 +471,88 @@ async function degreeBetween(viewerGithubId, targetGithubId, { pool = defaultPoo
471
471
  return { degree: f.is_third ? DEGREE_MAX : null, mutuals, truncated };
472
472
  }
473
473
 
474
+ // ---------------------------------------------------------------------------
475
+ // Read: the 2nd-degree frontier, ranked ("builders you may know", R21, task
476
+ // 1002297; ADR 0202 D3's frontier + D5's mutual-connections signal)
477
+ // ---------------------------------------------------------------------------
478
+
479
+ // mutualFrontier(viewer) → [{ github_id, mutual_count }] — the viewer's 1-hop
480
+ // expanded one level and grouped by candidate, D3's "2nd-degree frontier …
481
+ // ranked". `via` (which of the viewer's own connections bridges a candidate)
482
+ // is counted, never returned: the mutual COUNT is a suggestion signal, not a
483
+ // disclosure of who bridges it.
484
+ //
485
+ // The viewer's OWN 1-hop floors on accountActiveSql — the own-scope consent
486
+ // floor degreeBetween's `near` query already uses (D4 §2: a private account
487
+ // that connected with the viewer is still real inside the viewer's own
488
+ // scope). The CANDIDATE floors on accountVisibleSql — D5's "filtered per
489
+ // D4": a suggestion the viewer cannot address (private, provisional) is not
490
+ // a suggestion, and surfacing one would name a hidden builder by
491
+ // implication. Self and the viewer's own 1-hop are excluded — a suggestion
492
+ // names someone not already known.
493
+ //
494
+ // The 2-hop EXPANSION is capped too, not just v1 and the output — the same
495
+ // FANOUT_2 bound degreeBetween's `far` query puts on its own 2-hop frontier
496
+ // (D3), so a viewer with FANOUT_1 highly-connected neighbours cannot force
497
+ // an unbounded edges scan (up to FANOUT_1² rows uncapped). `edgeCap`
498
+ // defaults to FANOUT_2 rather than to `resultCap` on purpose: the two bound
499
+ // different things (raw edges scanned vs. candidates returned) and must not
500
+ // be read as one knob wearing two names.
501
+ async function mutualFrontier(viewerGithubId, { pool = defaultPool, fanoutCap = FANOUT_1, resultCap = 40, edgeCap = FANOUT_2 } = {}) {
502
+ const { rows } = await pool.query(
503
+ `WITH v1 AS (
504
+ SELECT r.gid FROM (${neighboursSql('$1')}) r
505
+ JOIN platform_identity_accounts pa ON pa.github_id = r.gid
506
+ WHERE ${accountActiveSql('pa')}
507
+ ORDER BY r.gid LIMIT $2
508
+ ),
509
+ edges AS (
510
+ SELECT gid, via FROM (
511
+ SELECT c.addressee_github_id AS gid, c.requester_github_id AS via
512
+ FROM platform_identity_connections c
513
+ JOIN v1 ON v1.gid = c.requester_github_id
514
+ WHERE c.status = 'accepted'
515
+ UNION ALL
516
+ SELECT c.requester_github_id AS gid, c.addressee_github_id AS via
517
+ FROM platform_identity_connections c
518
+ JOIN v1 ON v1.gid = c.addressee_github_id
519
+ WHERE c.status = 'accepted'
520
+ ) raw
521
+ ORDER BY gid, via
522
+ LIMIT $4
523
+ )
524
+ SELECT e.gid::text AS github_id, count(DISTINCT e.via)::int AS mutual_count
525
+ FROM edges e
526
+ JOIN platform_identity_accounts pa ON pa.github_id = e.gid
527
+ WHERE e.gid <> $1::bigint
528
+ AND e.gid NOT IN (SELECT gid FROM v1)
529
+ AND ${accountVisibleSql('pa')}
530
+ GROUP BY e.gid
531
+ ORDER BY mutual_count DESC, e.gid ASC
532
+ LIMIT $3`,
533
+ [viewerGithubId, fanoutCap, resultCap, edgeCap],
534
+ );
535
+ return rows;
536
+ }
537
+
538
+ // The viewer's OWN open or in-cooldown counterparties — every github_id a
539
+ // pending ask, an accepted connection, or a still-cooling decline already
540
+ // names. "Builders you may know" (D5) excludes each of these SILENTLY: a
541
+ // suggestion the viewer cannot act on (already open), or whose addressee
542
+ // just declined (D2's swallowed re-ask), is not a suggestion.
543
+ async function excludedSuggestionIds(viewerGithubId, { pool = defaultPool } = {}) {
544
+ const { rows } = await pool.query(
545
+ `SELECT DISTINCT (CASE WHEN requester_github_id = $1::bigint THEN addressee_github_id
546
+ ELSE requester_github_id END)::text AS github_id
547
+ FROM platform_identity_connections
548
+ WHERE (requester_github_id = $1::bigint OR addressee_github_id = $1::bigint)
549
+ AND (status IN ('pending', 'accepted')
550
+ OR (status = 'rejected' AND resolved_at > now() - make_interval(days => $2::int)))`,
551
+ [viewerGithubId, DECLINE_COOLDOWN_DAYS],
552
+ );
553
+ return new Set(rows.map((r) => r.github_id));
554
+ }
555
+
474
556
  module.exports = {
475
557
  CONNECTION_STATUSES,
476
558
  REQUEST_EXPIRY_DAYS,
@@ -488,4 +570,6 @@ module.exports = {
488
570
  listConnections,
489
571
  listRequests,
490
572
  degreeBetween,
573
+ mutualFrontier,
574
+ excludedSuggestionIds,
491
575
  };
@@ -17,6 +17,12 @@
17
17
  // GET /connections — my accepted connections. count is the
18
18
  // array's own length (no hidden remainder).
19
19
  // GET /connections/requests — my inbox + outbox; stamps seen_at.
20
+ // GET /connections/suggestions — "builders you may know" (R21, task
21
+ // 1002297; ADR 0202 D5): mutuals + shared
22
+ // projects, ranked and explained. The
23
+ // candidate pool is the SESSION's own
24
+ // frontier, so there is no counterparty
25
+ // parameter to misuse.
20
26
  // GET /connections/degree/:handle — the viewer-relative label 0|1|2|3|null
21
27
  // plus the mutuals it is computed from.
22
28
  // An OWN-SCOPE read (D4 §4): this is how a
@@ -40,6 +46,7 @@ const express = require('express');
40
46
  const api = require('../../../src/module-api');
41
47
  const pi = require('../platform-identity');
42
48
  const conn = require('../connections');
49
+ const suggest = require('../connection-suggestions');
43
50
  const { isAccountVisible, isAccountActive } = require('../account-visibility');
44
51
 
45
52
  const { pool, requireBuilder, validateOrRespond, parsePagination } = api;
@@ -123,6 +130,23 @@ module.exports = function connectionRoutes() {
123
130
  }
124
131
  });
125
132
 
133
+ // rank: any-builder — "builders you may know" (R21, task 1002297; ADR 0202
134
+ // D5). Own-scope by construction: the candidate pool is the SESSION's own
135
+ // frontier, and every candidate already clears the same visibility floor
136
+ // the rest of the feature does (D4), so nothing here can reveal a hidden
137
+ // builder. count is the array's own length, same rule as every list above.
138
+ router.get('/connections/suggestions', requireBuilder, async (req, res) => {
139
+ try {
140
+ const me = await actingAccount(req.builder);
141
+ if (me.fail) return res.fail(...me.fail);
142
+ const suggestions = await suggest.listConnectionSuggestions({ githubId: me.githubId, pool });
143
+ res.json({ suggestions, count: suggestions.length });
144
+ } catch (err) {
145
+ log.error({ err: err && err.message }, 'GET /connections/suggestions failed');
146
+ res.fail('connection_suggestions_failed', { status: 500, message: 'internal error' });
147
+ }
148
+ });
149
+
126
150
  // rank: any-builder — the degree label between the CALLER and one handle, plus
127
151
  // the mutuals it was computed from (own-scope read, D4 §4). Bounded by
128
152
  // construction: degreeBetween caps every level and answers null past the caps.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1018",
3
+ "version": "1.19.1020",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1018",
9
+ "version": "1.19.1020",
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.1018",
3
+ "version": "1.19.1020",
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
@@ -605,6 +605,12 @@ async function deactivateBuilder({ builderId, reason, actorBuilderId }) {
605
605
  WHERE builder_id = $1 AND revoked_at IS NULL`,
606
606
  [builderId]
607
607
  );
608
+ // PRIVACY (task 1003338 / ADR 0332): scrub the stored GitHub OAuth repo
609
+ // token in the SAME transaction — github_oauth_tokens is core's own table
610
+ // (not module-owned), so it gets the direct treatment the session revoke
611
+ // above gets. deleteGithubToken is a plain DELETE; safe to call even when
612
+ // no row exists.
613
+ await deleteGithubToken({ builderId, p: client });
608
614
  return { updated, activeClaims };
609
615
  });
610
616
  // Source-access clawback (ADR 0031 §6 / #600): a deactivated builder loses
@@ -616,6 +622,16 @@ async function deactivateBuilder({ builderId, reason, actorBuilderId }) {
616
622
  await seams.emitAsync('builder.box-reconcile-needed', {
617
623
  builderId, rank: 'xenos', status: 'inactive', actor: `builder-exit:builder#${actorBuilderId}`,
618
624
  });
625
+ // PRIVACY (task 1003338 / ADR 0332): teardown port for a MODULE-owned
626
+ // builder-keyed secret (today: art-pipeline's Gemini BYOK key,
627
+ // gemini_key_enc/last4/set_at — CRUD moved out of core under ADR 0102, so
628
+ // core does not touch those columns directly). Best-effort, same as
629
+ // box-reconcile-needed above: a module that stores such a secret listens
630
+ // for this event and clears its own store; on a vanilla instance (no
631
+ // listener registered) it is a no-op.
632
+ await seams.emitAsync('builder.secrets-scrub-needed', {
633
+ builderId, actor: `builder-exit:builder#${actorBuilderId}`,
634
+ });
619
635
  return {
620
636
  builder: updated[0],
621
637
  claims_released: activeClaims.map((c) => c.task_id),
@@ -646,6 +662,12 @@ async function reactivateBuilder({ builderId, actorBuilderId }) {
646
662
  // Rank is intentionally left at whatever it is (xenos after a deactivation):
647
663
  // an Archon re-promotes deliberately via PATCH /builders/:id/rank.
648
664
  //
665
+ // Same posture for the secrets deactivateBuilder scrubs (task 1003338 /
666
+ // ADR 0332): NEITHER the deleted github_oauth_tokens row NOR the
667
+ // module-owned Gemini BYOK key is restored here, deliberately. A
668
+ // returning builder re-enters their own key; do not "helpfully" resurrect
669
+ // a stored credential on reactivation.
670
+ //
649
671
  // REACTIVATION IS THE EXPOSURE (task 1003193). Rank does not move here, but
650
672
  // status flipping back to 'active' makes whatever seeded rank-role the builder
651
673
  // still holds live again — getActiveBuildersWithRank, builderHoldsRankActive,