@bongos/core 1.19.1019 → 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.
@@ -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.1019",
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.1019",
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.1019",
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/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1019'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1020'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');