@bongos/core 1.19.425 → 1.19.427

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/.bongos-core.json +66 -31
  2. package/docs/adr/0210-scouting-graduates-on-an-instance-asserted-owner-claim.md +100 -0
  3. package/docs/adr/0212-connections-on-the-profile-surface-and-addressing.md +61 -0
  4. package/docs/adr/README.md +2 -0
  5. package/docs/api/openapi.json +1 -1
  6. package/docs/copy-inventory.md +14 -6
  7. package/docs/copy-registry.json +91 -9
  8. package/docs/file-map.md +3 -1
  9. package/docs/module-api-changelog.md +4 -0
  10. package/modules/hall-ui/public/people.js +5 -5
  11. package/modules/hall-ui/public/profile-connections.js +304 -0
  12. package/modules/hall-ui/public/profile.html +16 -1
  13. package/modules/hall-ui/public/profile.js +5 -0
  14. package/modules/hall-ui/public/settings.html +2 -2
  15. package/modules/hall-ui/public/settings.js +12 -12
  16. package/modules/hall-ui/public/style.css +38 -0
  17. package/modules/platform-identity/account-visibility.js +47 -1
  18. package/modules/platform-identity/platform-identity.js +14 -11
  19. package/modules/platform-identity/routes/scouting.js +61 -21
  20. package/modules/platform-identity/routes/sso.js +6 -0
  21. package/modules/platform-identity/scouting-authz.js +139 -0
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/scripts/hall-preview/server.js +3 -0
  25. package/src/bongos/auth.js +59 -3
  26. package/src/module-api.js +1 -1
  27. package/tests/account_privacy_flags.mjs +23 -13
  28. package/tests/effective_visibility_predicate.mjs +49 -4
  29. package/tests/profile_connections_ui.mjs +874 -0
  30. package/tests/scouting_owner_access_and_reach.mjs +544 -0
  31. package/tests/scouting_page_gate.mjs +185 -0
@@ -0,0 +1,304 @@
1
+ // modules/hall-ui/public/profile-connections.js — section 6 of the builder
2
+ // profile page: connections, degree labels, and the connect button (R20,
3
+ // task 1002296 / goal 1000045 criterion C6; ADR 0202, ADR 0207).
4
+ //
5
+ // WHAT IT RENDERS, and to whom:
6
+ // * on YOUR OWN profile — the pending requests in both directions (incoming
7
+ // ones you Accept or Decline, outgoing ones you Withdraw) and your accepted
8
+ // connections, each 1st degree by definition, each removable.
9
+ // * on ANOTHER builder's profile — the viewer-relative degree label, the
10
+ // mutual connections it was computed from, and the connect affordance.
11
+ //
12
+ // HUB-ONLY, AND IT DEGRADES TO NOTHING. Connections are account-to-account on
13
+ // the platform hub (modules/platform-identity); on an instance without that
14
+ // module every route here is absent, every call 404s, and the section never
15
+ // reveals — the same "reveal only when the data is there" contract the
16
+ // cross-project section already keeps on this page.
17
+ //
18
+ // FOUR THINGS THIS FILE MUST NOT DO. Each is a privacy decision the API made
19
+ // deliberately, and the UI is the last place it can be undone:
20
+ //
21
+ // 1. `degree: null` is OUT OF NETWORK, never "not connected" (ADR 0202
22
+ // Consequences). Past the fan-out caps or past three hops the API stops
23
+ // searching and says so honestly; rendering that as the absence of a
24
+ // relationship states something the query never established.
25
+ // 2. The uncertainty copy beside an out-of-network label is CONSTANT. The
26
+ // API's `truncated` describes the VIEWER's own expansion; the target's
27
+ // 1-hop truncation is deliberately unreported (ADR 0207 L2) because
28
+ // reporting it leaks a bit about a third party's degree. Copy that varied
29
+ // with `truncated` would rebuild that side channel in words.
30
+ // 3. A sent request claims exactly `201 { status: 'pending' }` and nothing
31
+ // more (ADR 0207 D6) — no id, no timestamp, no "they will be notified",
32
+ // and no remark when a re-ask inside the 90-day decline cooldown is
33
+ // silently swallowed (ADR 0207 L1). "Request sent" is the whole claim.
34
+ // 4. `seen_at` never reaches the screen. It was stripped from outgoing rows
35
+ // so a requester cannot learn when their request was read; rendering it on
36
+ // the incoming side re-creates that signal the moment a screenshot passes
37
+ // between the two people.
38
+ //
39
+ // COUNTS ARE ARRAY LENGTHS. Every number here is the length of the list the
40
+ // viewer was actually handed — never a total carried beside a filtered list,
41
+ // which is a leak by subtraction (ADR 0171 D4 / ADR 0202 D4).
42
+ //
43
+ // NOTHING HERE IS AUTHORITY (ADR 0016): a connection grants no rank, no credits
44
+ // and no admission. Nothing rendered here is rollup-derived either, which is
45
+ // why `hide_stats` does not suppress it — hiding your stats hides the numbers
46
+ // beside an edge, not the edge itself (ADR 0202 D4 §3).
47
+ //
48
+ // ADDRESSING. The connections API names a counterparty by HANDLE; this page
49
+ // knows only a builder's github_login. It hands that login to
50
+ // GET /connections/degree/:handle, which resolves the current handle or the
51
+ // single previous-handle rename slot and echoes the CANONICAL handle back —
52
+ // and that echoed handle, never the login, is what every write here sends. A
53
+ // login resolving to neither answers 404 and the section stays hidden, the same
54
+ // fail-closed answer a private or provisional account gets. The handle-addressed
55
+ // hub page (/u/<handle>) will not need this hop at all.
56
+
57
+ (() => {
58
+ 'use strict';
59
+
60
+ const API = '/api/bongos';
61
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
62
+ const { escapeHtml, $, fmtDate } = window.OTB;
63
+ const toast = (msg) => window.OTB.toast(msg, { duration: 2600, clear: true });
64
+
65
+ // The only degrees that have a name. Anything else — null, or a number the
66
+ // API never promised — falls through to "out of network" rather than being
67
+ // invented a label (note 1 above).
68
+ const DEGREE_SHORT = { 0: 'You', 1: '1st', 2: '2nd', 3: '3rd' };
69
+ const DEGREE_LINE = {
70
+ 0: 'This is your own profile.',
71
+ 1: 'You are connected.',
72
+ 2: 'You know someone they know.',
73
+ 3: 'Three connections away in your network.',
74
+ };
75
+ const OUT_OF_NETWORK = 'Out of network';
76
+ // CONSTANT by decision — it must read the same whether or not the response
77
+ // said `truncated` (note 2 above).
78
+ const OUT_OF_NETWORK_WHY = 'No path to this builder through the connections you can see. '
79
+ + 'There may be none, or it may run through someone whose account is not visible to you.';
80
+ // The whole claim a 201 supports, in markup, used by both the first render
81
+ // and the click that earns it.
82
+ const SENT_HTML = '<p class="conn-act"><button class="btn-ghost" type="button" disabled>Request sent</button></p>';
83
+
84
+ const known = (degree) => Object.prototype.hasOwnProperty.call(DEGREE_SHORT, degree);
85
+ const arr = (v) => (Array.isArray(v) ? v : []);
86
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
87
+
88
+ // The last render's inputs, so an accept/decline/remove can repaint the
89
+ // section from the server rather than patching the DOM it just changed.
90
+ let last = null;
91
+
92
+ // ---- shared cells ---------------------------------------------------------
93
+
94
+ // One counterparty, rendered from the only three columns the API projects
95
+ // for one (handle, display_name, avatar_url) — nothing rollup-derived, so
96
+ // there is nothing here for hide_stats to suppress.
97
+ function personHtml(row) {
98
+ const name = escapeHtml(row.display_name || row.handle || 'Builder');
99
+ const avatar = row.avatar_url
100
+ ? `<img class="conn-person__avatar" src="${escapeHtml(row.avatar_url)}" alt="">`
101
+ : '<span class="conn-person__avatar" aria-hidden="true"></span>';
102
+ const handle = row.handle ? `<span class="conn-person__handle">@${escapeHtml(row.handle)}</span>` : '';
103
+ return `<span class="conn-person">${avatar}<span class="conn-person__name">${name}</span>${handle}</span>`;
104
+ }
105
+
106
+ function btn(act, id, label) {
107
+ return `<button class="btn-ghost" type="button" data-act="${escapeHtml(act)}" data-id="${escapeHtml(id)}">${escapeHtml(label)}</button>`;
108
+ }
109
+
110
+ // ---- your own profile -----------------------------------------------------
111
+
112
+ // Every row is an accepted connection of yours, so its degree is 1 by
113
+ // definition — no per-row query, and no chance of a fan-out cap making the
114
+ // label wrong (ADR 0207 D2: a cap may make an answer less specific, never
115
+ // wrong). `connected_at` is the edge's own timestamp, not a rollup number.
116
+ function connectionRow(row) {
117
+ return `<tr>
118
+ <td>${personHtml(row)}</td>
119
+ <td><span class="conn-degree conn-degree--1">1st</span></td>
120
+ <td class="ledger__date">${row.connected_at ? escapeHtml(fmtDate(row.connected_at)) : '—'}</td>
121
+ <td>${btn('remove', row.id, 'Remove')}</td>
122
+ </tr>`;
123
+ }
124
+
125
+ function requestsHtml(incoming, outgoing) {
126
+ const inbox = incoming.map((r) => `<li class="conn-req">${personHtml(r)}`
127
+ + (r.note ? `<p class="conn-req__note">${escapeHtml(r.note)}</p>` : '')
128
+ + `<span class="conn-req__acts">${btn('accept', r.id, 'Accept')}${btn('decline', r.id, 'Decline')}</span></li>`).join('');
129
+ const outbox = outgoing.map((r) => `<li class="conn-req">${personHtml(r)}`
130
+ + '<span class="conn-req__state">Request sent</span>'
131
+ + `<span class="conn-req__acts">${btn('withdraw', r.id, 'Withdraw')}</span></li>`).join('');
132
+ return (incoming.length ? `<h3 class="conn-sub">${escapeHtml(plural(incoming.length, 'builder'))} asking to connect</h3><ul class="conn-reqs">${inbox}</ul>` : '')
133
+ + (outgoing.length ? `<h3 class="conn-sub">${escapeHtml(plural(outgoing.length, 'request'))} you have sent</h3><ul class="conn-reqs">${outbox}</ul>` : '');
134
+ }
135
+
136
+ // Your own connections + both request queues. Returns false when the hub
137
+ // routes are absent or the session is gone, so the caller leaves the section
138
+ // hidden rather than rendering an empty shell.
139
+ async function renderOwn(body) {
140
+ const [list, reqs] = await Promise.all([
141
+ api.request('GET', `${API}/connections`),
142
+ api.request('GET', `${API}/connections/requests`),
143
+ ]);
144
+ if (!list.ok || !reqs.ok) return false;
145
+ const connections = arr(list.data && list.data.connections);
146
+ const incoming = arr(reqs.data && reqs.data.incoming);
147
+ const outgoing = arr(reqs.data && reqs.data.outgoing);
148
+ body.innerHTML = requestsHtml(incoming, outgoing)
149
+ + `<p class="scroll__lede">${escapeHtml(plural(connections.length, 'connection'))}.</p>`
150
+ + '<div id="conn-ledger"></div>';
151
+ const mount = $('#conn-ledger');
152
+ if (mount) {
153
+ const pager = window.OTBKit.makePager('conn', 'profile.connections.perPage');
154
+ window.OTBKit.makeLedger({
155
+ mount,
156
+ columns: ['Builder', 'Degree', { label: 'Connected', class: 'ledger__date' }, ''],
157
+ rowHtml: connectionRow,
158
+ emptyMsg: 'No connections yet.',
159
+ pager,
160
+ }).render(connections);
161
+ }
162
+ return true;
163
+ }
164
+
165
+ // ---- someone else's profile -----------------------------------------------
166
+
167
+ function degreeHtml(degree, mutuals) {
168
+ const chip = `<span class="conn-degree conn-degree--${known(degree) ? degree : 'out'}">`
169
+ + `${escapeHtml(known(degree) ? DEGREE_SHORT[degree] : OUT_OF_NETWORK)}</span>`;
170
+ const line = known(degree) ? DEGREE_LINE[degree] : OUT_OF_NETWORK_WHY;
171
+ // The count is the length of the names beside it — never a total the
172
+ // response carried, which would disclose a filtered mutual by subtraction.
173
+ const mutual = mutuals.length
174
+ ? `<p class="scroll__lede">${escapeHtml(plural(mutuals.length, 'connection'))} in common: `
175
+ + `${mutuals.map((m) => escapeHtml(`@${m.handle || 'unknown'}`)).join(', ')}</p>`
176
+ : '';
177
+ return `<p class="conn-degree-line">${chip} ${escapeHtml(line)}</p>${mutual}`;
178
+ }
179
+
180
+ // Which pending request, if any, already stands with this handle — read from
181
+ // the caller's OWN inbox and outbox, the only place the API says anything
182
+ // about one. An empty outbox is not evidence a request was refused: a re-ask
183
+ // inside the decline cooldown writes nothing (ADR 0207 L1), so absence means
184
+ // "nothing is pending" and the UI draws no further conclusion from it.
185
+ async function pendingWith(handle) {
186
+ const res = await api.request('GET', `${API}/connections/requests`);
187
+ if (!res.ok) return null;
188
+ const same = (r) => r && typeof r.handle === 'string' && r.handle.toLowerCase() === handle.toLowerCase();
189
+ const incoming = arr(res.data && res.data.incoming).find(same);
190
+ if (incoming) return { dir: 'incoming', id: incoming.id };
191
+ const outgoing = arr(res.data && res.data.outgoing).find(same);
192
+ return outgoing ? { dir: 'outgoing', id: outgoing.id } : null;
193
+ }
194
+
195
+ function actionHtml(degree, handle, pending) {
196
+ if (degree === 0) return ''; // your own account reached by ?id= — nothing to ask
197
+ if (degree === 1) return '<p class="conn-act"><span class="conn-state">Connected</span></p>';
198
+ if (pending && pending.dir === 'incoming') {
199
+ return '<p class="conn-act"><span class="conn-state">They have asked to connect.</span>'
200
+ + `${btn('accept', pending.id, 'Accept')}${btn('decline', pending.id, 'Decline')}</p>`;
201
+ }
202
+ if (pending && pending.dir === 'outgoing') return SENT_HTML;
203
+ return `<p class="conn-act"><button class="btn-ghost" type="button" data-act="connect" data-handle="${escapeHtml(handle)}">Connect</button></p>`;
204
+ }
205
+
206
+ // The viewer-relative view. A 404 here is the deliberately indistinguishable
207
+ // set — unknown handle, provisional account, private stranger — and all of
208
+ // them mean the same thing to this page: there is nothing to offer, so the
209
+ // section stays hidden and no connect button is drawn.
210
+ async function renderOther(body, login) {
211
+ if (!login) return false;
212
+ const res = await api.request('GET', `${API}/connections/degree/${encodeURIComponent(login)}`);
213
+ if (res.status === 403) {
214
+ // About the VIEWER, not the target: their own account has not been
215
+ // activated at the hub yet, so they cannot take part at all.
216
+ body.innerHTML = '<p class="scroll__lede">Sign in to the platform once to activate your profile before connecting.</p>';
217
+ return true;
218
+ }
219
+ if (!res.ok) return false;
220
+ const data = res.data || {};
221
+ const handle = typeof data.handle === 'string' ? data.handle : null;
222
+ if (!handle) return false;
223
+ const mutuals = arr(data.mutuals);
224
+ body.innerHTML = degreeHtml(data.degree, mutuals) + actionHtml(data.degree, handle, await pendingWith(handle));
225
+ return true;
226
+ }
227
+
228
+ // ---- actions --------------------------------------------------------------
229
+
230
+ async function connect(handle) {
231
+ const res = await api.request('POST', `${API}/connections`, { body: { handle } });
232
+ // 201 { status: 'pending' } is byte-identical for a request that was written
233
+ // and one swallowed by the cooldown. "Request sent" claims exactly that.
234
+ if (res.status === 201) return { html: SENT_HTML, message: 'Request sent.' };
235
+ if (res.status === 409) return { message: 'There is already a request or a connection between you.' };
236
+ if (res.status === 403) return { message: 'Activate your platform profile before connecting.' };
237
+ // 404 is absent / provisional / private, indistinguishable on purpose.
238
+ if (res.status === 404) return { message: 'That builder cannot be connected with right now.' };
239
+ return { message: 'Could not send the request.' };
240
+ }
241
+
242
+ const ROW_ACTIONS = {
243
+ accept: { call: (id) => api.request('POST', `${API}/connections/${encodeURIComponent(id)}/accept`), message: 'Connected.' },
244
+ decline: { call: (id) => api.request('POST', `${API}/connections/${encodeURIComponent(id)}/decline`), message: 'Declined.' },
245
+ remove: { call: (id) => api.request('DELETE', `${API}/connections/${encodeURIComponent(id)}`), message: 'Connection removed.' },
246
+ withdraw: { call: (id) => api.request('DELETE', `${API}/connections/${encodeURIComponent(id)}`), message: 'Request withdrawn.' },
247
+ };
248
+
249
+ async function onClick(ev) {
250
+ const el = ev.target && ev.target.closest ? ev.target.closest('button[data-act]') : null;
251
+ if (!el) return;
252
+ const act = el.dataset.act;
253
+ el.disabled = true;
254
+ try {
255
+ if (act === 'connect') {
256
+ const out = await connect(el.dataset.handle);
257
+ // Replace only the action line: a 201 tells us nothing about the rest
258
+ // of the page, so nothing else is restated.
259
+ if (out.html) { const body = $('#conn-body'); if (body) body.innerHTML = body.innerHTML.replace(/<p class="conn-act">[\s\S]*?<\/p>/, out.html); }
260
+ toast(out.message);
261
+ return;
262
+ }
263
+ const spec = ROW_ACTIONS[act];
264
+ if (!spec) return;
265
+ const res = await spec.call(el.dataset.id);
266
+ if (!res.ok && res.status !== 204) { toast('That request is no longer available.'); }
267
+ else toast(spec.message);
268
+ if (last) await render(last.data, last.selfId);
269
+ } catch (err) {
270
+ console.error('[profile-connections] action failed', err);
271
+ toast('Something went wrong. Try again.');
272
+ } finally {
273
+ el.disabled = false;
274
+ }
275
+ }
276
+
277
+ // ---- entry ----------------------------------------------------------------
278
+
279
+ // Called by profile.js once the profile response has landed. Reveals the
280
+ // section only when the hub answered with something to show; any other
281
+ // outcome — module absent, signed out, target not addressable — leaves the
282
+ // markup's `hidden` in place.
283
+ async function render(data, selfId) {
284
+ const section = $('#conn-scroll');
285
+ const body = $('#conn-body');
286
+ if (!section || !body) return;
287
+ last = { data, selfId };
288
+ const builder = (data && data.builder) || {};
289
+ const isSelf = selfId != null && String(builder.id) === String(selfId);
290
+ try {
291
+ const shown = isSelf ? await renderOwn(body) : await renderOther(body, builder.github_login);
292
+ section.hidden = !shown;
293
+ if (shown && body.addEventListener && !body.dataset.connWired) {
294
+ body.dataset.connWired = '1';
295
+ body.addEventListener('click', onClick);
296
+ }
297
+ } catch (err) {
298
+ console.error('[profile-connections] failed to load', err);
299
+ section.hidden = true;
300
+ }
301
+ }
302
+
303
+ window.OTBConnections = { render };
304
+ })();
@@ -107,9 +107,20 @@
107
107
  </div>
108
108
  </section>
109
109
 
110
+ <!-- Connections — section 6 of the profile-page spec (R20, task
111
+ 1002296; ADR 0202). Hub-only, like the cross-project record:
112
+ profile-connections.js reveals it only when the hub's connections
113
+ API answers, so on an instance without platform-identity the
114
+ routes are absent and the section never renders. -->
115
+ <section class="scroll" id="conn-scroll" aria-labelledby="conn-h" hidden>
116
+ <h2 id="conn-h" class="scroll__h">Connections</h2>
117
+ <div id="conn-body"></div>
118
+ </section>
119
+
110
120
  <!-- Reserved slots — connections, guilds, and actions (profile-page
111
121
  spec sections 6–8) mount here in later phases; keeping the order
112
- fixed now means the page is not re-laid-out twice (Decision 5). -->
122
+ fixed now means the page is not re-laid-out twice (Decision 5).
123
+ Section 6 landed above (task 1002296); 7 and 8 are still to come. -->
113
124
  </main>
114
125
 
115
126
  <footer class="app-foot" id="app-foot"></footer>
@@ -130,6 +141,10 @@
130
141
  <!-- hall-kit (window.OTBKit) — profile.js renders ship-time task visuals through
131
142
  its shared thumbnail helper (task 1003109), so it must load first. -->
132
143
  <script src="/builders/hall-kit.js?v=2026-08-27-activity"></script>
144
+ <!-- Connections (section 6). Loaded BEFORE profile.js so window.OTBConnections
145
+ exists by the time renderProfile runs; profile.js guards on it anyway, so a
146
+ page that omits this script simply has no connections section. -->
147
+ <script src="/builders/profile-connections.js?v=2026-08-29-r20"></script>
133
148
  <script src="/builders/profile.js?v=2026-08-27-activity"></script>
134
149
  </body>
135
150
  </html>
@@ -534,6 +534,10 @@
534
534
  // The activity log needs a session (private goals + the idea trail), so it
535
535
  // keys off whether /me resolved — the same signal the self-detect uses.
536
536
  renderActivity(builder.id, selfId != null);
537
+ // Connections (section 6, task 1002296) — a separate hub-only script that
538
+ // reveals its own section or leaves it hidden. Optional by design: a page
539
+ // that does not load it simply has no connections section.
540
+ if (window.OTBConnections) window.OTBConnections.render(data, selfId);
537
541
  }
538
542
 
539
543
  // Render the friendly "no builder chosen" empty state for a signed-out visitor
@@ -557,6 +561,7 @@
557
561
  const worksLede = $('#works-lede'); if (worksLede) worksLede.hidden = true;
558
562
  const xp = $('#xp-scroll'); if (xp) xp.hidden = true;
559
563
  const act = $('#activity-scroll'); if (act) act.hidden = true;
564
+ const conn = $('#conn-scroll'); if (conn) conn.hidden = true;
560
565
  }
561
566
 
562
567
  async function fetchProfile(id, selfId) {
@@ -108,8 +108,8 @@
108
108
  <div id="privacy-rows"></div>
109
109
  <p class="settings-status" id="privacy-status" role="status" aria-live="polite"></p>
110
110
  <p class="section-note">Your profile page setting applies now — private already answers "not found"
111
- everywhere your profile is read. Recruiter discoverability is saved to your
112
- account and starts applying as the recruiting surfaces land.</p>
111
+ everywhere your profile is read. Recruiter discoverability applies now too — switching
112
+ it off takes you out of the People directory immediately, and back on puts you there again.</p>
113
113
  </section>
114
114
 
115
115
  <!-- CRAFT — discipline preferences (GET /me, PATCH /me/disciplines). -->
@@ -596,15 +596,15 @@
596
596
  // toggle is deliberately BINARY (spec D1: "one column, one settings toggle");
597
597
  // resetting back to null stays an API affordance, not a third UI state.
598
598
  //
599
- // COPY RULE — a toggle may promise exactly what is enforced, and the two rows
600
- // are at different stages, so they speak in different tenses (task 1002967).
601
- // VISIBILITY is live: every public read joins the shared effective-visibility
602
- // predicate, so private really does 404 — including to the owner, which the
603
- // row must say out loud, because a page that vanishes for you too is
604
- // surprising unless you are told where your own record went (here, Settings).
605
- // DISCOVERABILITY is still stored-only: no recruiting surface reads it yet, so
606
- // its row keeps the future tense until that lands. A toggle that claims
607
- // protection it does not have is worse than no toggle.
599
+ // COPY RULE — a toggle may promise exactly what is enforced, and BOTH rows now
600
+ // are, one task apart. VISIBILITY went live with task 1002967: every public
601
+ // read joins the shared effective-visibility predicate, so private really does
602
+ // 404 — including to the owner, which the row must say out loud: a page that
603
+ // vanishes for you too is surprising unless you are told where your record went
604
+ // (here, Settings). DISCOVERABILITY went live with task 1002290:
605
+ // listScoutingRollup joins the reach predicate, so opting out really does empty
606
+ // the People directory of you. It names THAT surface and no wider set: the D3
607
+ // sliver is unbuilt, and no guard holds a promise about a surface that is not.
608
608
  function renderPrivacy(data) {
609
609
  const wrap = $('#privacy-rows');
610
610
  if (!wrap) return;
@@ -618,7 +618,7 @@
618
618
  privacyRow('visibility', 'Public profile page', isPublic ? 'Public' : 'Private', isPublic,
619
619
  'Public means anyone can open your profile page and find you by name. Private hides the page from everyone — including you: it answers "not found" to every visitor, and you read your own record here in Settings instead.', '') +
620
620
  privacyRow('discoverable', 'Discoverable by recruiters', discoverable ? 'On' : 'Off', discoverable,
621
- `Recruiters looking for builders will be able to find you. ${grant}`,
621
+ `Recruiters looking for builders can find you. ${grant} Turning this off takes you out of the People directory straight away.`,
622
622
  follows ? ' <span class="voices__default">· follows your profile setting</span>' : '');
623
623
  }
624
624
 
@@ -645,8 +645,8 @@
645
645
  : 'Saved — your profile page is set to private.';
646
646
  }
647
647
  return on
648
- ? 'Saved — you are set to be findable by recruiters.'
649
- : 'Saved — you are set to stay out of recruiting search.';
648
+ ? 'Saved — recruiters can find you.'
649
+ : 'Saved — you are out of the People directory.';
650
650
  }
651
651
 
652
652
  // PATCH any own-scoped privacy flag (routes/profile.js): { hide_stats } and/or
@@ -1107,6 +1107,44 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
1107
1107
 
1108
1108
  .disc-pref { margin-right: 4px; }
1109
1109
 
1110
+ /* ---------- connections (profile section 6, task 1002296) ---------- */
1111
+
1112
+ .conn-person { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
1113
+ .conn-person__avatar {
1114
+ width: 22px; height: 22px; border-radius: 50%; flex: none;
1115
+ background: var(--surface-2); border: 1px solid var(--border); object-fit: cover;
1116
+ }
1117
+ .conn-person__name { font-weight: 500; }
1118
+ .conn-person__handle { color: var(--ink-faint); font-size: 12.5px; }
1119
+
1120
+ /* The degree chip. `--out` is the null answer — deliberately the quietest of
1121
+ the four, because "out of network" is an absence of knowledge, not a rank. */
1122
+ /* nowrap + flex:none — "Out of network" is the longest label and must stay one
1123
+ pill; wrapped, it reads as two chips. */
1124
+ .conn-degree {
1125
+ display: inline-block; flex: none; white-space: nowrap;
1126
+ padding: 1px 7px; border-radius: 999px;
1127
+ font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
1128
+ border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--ink-soft);
1129
+ }
1130
+ .conn-degree--1 { background: var(--ok-weak); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 30%, transparent); }
1131
+ .conn-degree--2 { background: var(--info-weak); color: var(--info); border-color: color-mix(in srgb, var(--info) 30%, transparent); }
1132
+ .conn-degree--3 { background: var(--surface-2); color: var(--ink-soft); }
1133
+ .conn-degree--out { background: none; color: var(--ink-faint); border-style: dashed; }
1134
+
1135
+ .conn-degree-line { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin: 0 0 6px; font-size: 13.5px; color: var(--ink-soft); }
1136
+ .conn-act { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 10px 0 0; }
1137
+ .conn-state { font-size: 13px; color: var(--ink-faint); }
1138
+ .conn-sub { margin: 14px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-faint); }
1139
+ .conn-reqs { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
1140
+ .conn-req {
1141
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
1142
+ padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-1);
1143
+ }
1144
+ .conn-req__note { flex: 1 1 100%; margin: 0; font-size: 13px; color: var(--ink-soft); }
1145
+ .conn-req__state { font-size: 13px; color: var(--ink-faint); }
1146
+ .conn-req__acts { margin-left: auto; display: flex; gap: 6px; }
1147
+
1110
1148
  @media (max-width: 640px) {
1111
1149
  .profile { grid-template-columns: 48px minmax(0, 1fr); }
1112
1150
  .profile__avatar { width: 48px; height: 48px; }
@@ -28,8 +28,11 @@
28
28
  // rollupVisibleSql / isRollupVisible NUMBERS — visible AND NOT
29
29
  // hide_stats. ADR 0171 D2's predicate EXTENDED with the existence axis,
30
30
  // and COMPOSED from accountVisibleSql rather than restated.
31
- // isRecruiterDiscoverable REACH — the COALESCE, privacy
31
+ // recruiterReachSql / isRecruiterDiscoverable REACH — the COALESCE, privacy
32
32
  // spec D1. A reach predicate only: it never buys addressability.
33
+ // scoutingListableSql THE DIRECTORY — numbers AND reach,
34
+ // the ONE predicate the recruiting directory joins (ADR 0210, task
35
+ // 1002290). Composed, never restated.
33
36
  //
34
37
  // The rollup pair was originally left to "the rollup readers" to apply. Task
35
38
  // 1002967 moved it here for the reason this file exists: there are two such
@@ -137,6 +140,47 @@ function isRecruiterDiscoverable(account) {
137
140
  return account.account_visibility === 'public';
138
141
  }
139
142
 
143
+ // The SAME reach answer as a SQL fragment, for the read that filters in the
144
+ // database (task 1002290). It is the ONE spelling of that COALESCE outside the
145
+ // JS twin above — tests/effective_visibility_predicate.mjs fails any other file
146
+ // that writes it — so a directory query and a route can never disagree about who
147
+ // opted out. NULL still means "follow visibility", exactly as the column comment
148
+ // in platform_identity_012_account_privacy.sql says.
149
+ function recruiterReachSql(alias) {
150
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
151
+ throw new Error('recruiterReachSql: alias must be a bare SQL identifier');
152
+ }
153
+ const q = alias ? `${alias}.` : '';
154
+ return `COALESCE(${q}recruiter_discoverable, ${q}account_visibility = 'public')`;
155
+ }
156
+
157
+ // scoutingListableSql — THE predicate the recruiting directory joins: the rollup
158
+ // axis AND the reach axis (ADR 0210, task 1002290). listScoutingRollup is the
159
+ // one reader; /scouting and the hall's /people are its two surfaces.
160
+ //
161
+ // WHY REACH BELONGS HERE AND NOT IN rollupVisibleSql. The two axes answer
162
+ // different questions and only one surface asks both. `hide_stats` suppresses
163
+ // NUMBERS everywhere, for every reader; the recruiting opt-out removes an
164
+ // account from the RECRUITING SURFACE only. The community spec's D3 leaderboards
165
+ // say so out loud — "the recruiting opt-out does not affect leaderboards; it
166
+ // governs the recruiting surface only" — so folding reach into rollupVisibleSql
167
+ // would silently empty a future public leaderboard of every opted-out builder.
168
+ // A leaderboard reader must therefore join rollupVisibleSql, never this.
169
+ //
170
+ // Composed from rollupVisibleSql and recruiterReachSql rather than restated, so
171
+ // changing what "visible", "hidden" or "reachable" means moves this with it.
172
+ function scoutingListableSql(alias) {
173
+ // Validated HERE as well as in both delegates. The delegates do refuse a bad
174
+ // alias, but only because rollupVisibleSql happens to be evaluated first in the
175
+ // template literal below — a security property should not rest on the
176
+ // evaluation order of a string interpolation, and the error a caller sees
177
+ // should name the function they actually called.
178
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
179
+ throw new Error('scoutingListableSql: alias must be a bare SQL identifier');
180
+ }
181
+ return `(${rollupVisibleSql(alias)} AND ${recruiterReachSql(alias)})`;
182
+ }
183
+
140
184
  module.exports = {
141
185
  isAccountVisible,
142
186
  accountVisibleSql,
@@ -146,4 +190,6 @@ module.exports = {
146
190
  isRollupVisible,
147
191
  ACCOUNT_VISIBILITY,
148
192
  isRecruiterDiscoverable,
193
+ recruiterReachSql,
194
+ scoutingListableSql,
149
195
  };
@@ -20,7 +20,7 @@ const api = require('../../src/module-api');
20
20
  const visibility = require('./account-visibility');
21
21
 
22
22
  const { pool: defaultPool } = api;
23
- const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible } = visibility;
23
+ const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
24
24
 
25
25
  // Default authorization-code lifetime — deliberately short (~60s). A code is a
26
26
  // bearer secret that travels through the browser; a tight TTL bounds the replay
@@ -722,15 +722,18 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
722
722
  // disciplines union is computed separately in `disc`. Proven against real SQL by
723
723
  // tests/scouting_rollup_fanout.mjs.
724
724
  //
725
- // Consent by construction (ADR 0171 D2/D3, extended by task 1002967): only
726
- // builders whose account passes rollupVisibleSql — active, publicly visible, not
727
- // hidden — appear. /people and /scouting are live cross-project surfaces, so the
728
- // predicate lives IN the query, not in any caller's memory, and it is the SHARED
729
- // one so this list can never disagree with /profiles/:handle about who is
730
- // visible. Provisional, private and hidden builders drop out entirely; the
731
- // projection stays an explicit whitelist (no raw-row spread). A private account
732
- // that is recruiter-discoverable is absent here on purpose: the full rollup is
733
- // never its disclosure — the D3 sliver is (a separate authorization-scoped port).
725
+ // Consent by construction (ADR 0171 D2/D3, extended by tasks 1002967 + 1002290):
726
+ // only builders whose account passes scoutingListableSql — active, publicly
727
+ // visible, not hidden, AND not opted out of recruiting — appear. /people and
728
+ // /scouting are live cross-project surfaces, so the predicate lives IN the query,
729
+ // not in any caller's memory, and it is the SHARED one so this list can never
730
+ // disagree with /profiles/:handle about who is visible. Provisional, private,
731
+ // hidden and opted-out builders drop out entirely; the projection stays an
732
+ // explicit whitelist. A private account that is recruiter-discoverable is absent
733
+ // here on purpose: the full rollup is never its disclosure — the D3 sliver is.
734
+ // THIS IS THE RECRUITING DIRECTORY: a public LEADERBOARD must not reuse this
735
+ // reader (community spec D3 — the opt-out governs the recruiting surface only);
736
+ // it joins rollupVisibleSql instead. Full argument: scoutingListableSql.
734
737
  async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
735
738
  const { rows } = await pool.query(
736
739
  `WITH agg AS (
@@ -744,7 +747,7 @@ async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
744
747
  WHERE EXISTS (
745
748
  SELECT 1 FROM platform_identity_accounts pa
746
749
  WHERE pa.github_id = a.github_id
747
- AND ${rollupVisibleSql('pa')})
750
+ AND ${scoutingListableSql('pa')})
748
751
  GROUP BY a.github_id
749
752
  ), disc AS (
750
753
  SELECT x.github_id, array_agg(DISTINCT x.d) AS disciplines