@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.
- package/.bongos-core.json +49 -29
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0332-offboarding-secret-scrub-semantics.md +59 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +52 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +12 -11
- package/docs/copy-registry.json +23 -14
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -0
- package/modules/hall-ui/public/profile-connections.js +49 -5
- package/modules/platform-identity/connection-suggestions.js +219 -0
- package/modules/platform-identity/connections.js +84 -0
- package/modules/platform-identity/routes/connections.js +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/db.js +22 -0
- package/src/module-api.js +1 -1
- package/tests/connection_suggestions.mjs +225 -0
- package/tests/connections_api.mjs +51 -0
- package/tests/offboarding_secret_scrub.mjs +115 -0
- package/tests/profile_connections_ui.mjs +87 -0
package/docs/copy-registry.json
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
"generator": "scripts/gds/copy-inventory.js",
|
|
4
4
|
"goal": 1000074,
|
|
5
5
|
"counts": {
|
|
6
|
-
"strings":
|
|
7
|
-
"unique_ids":
|
|
8
|
-
"certain":
|
|
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":
|
|
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:
|
|
1485
|
-
"modules/hall-ui/public/profile-connections.js:
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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
|
```
|
package/docs/modules-contract.md
CHANGED
|
@@ -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
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|