@bongos/core 1.19.426 → 1.19.428

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 (30) hide show
  1. package/.bongos-core.json +65 -30
  2. package/docs/adr/0212-connections-on-the-profile-surface-and-addressing.md +61 -0
  3. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +71 -0
  4. package/docs/adr/README.md +2 -0
  5. package/docs/copy-inventory.md +34 -18
  6. package/docs/copy-registry.json +179 -25
  7. package/docs/file-map.md +2 -1
  8. package/docs/module-api-changelog.md +4 -0
  9. package/modules/hall-ui/public/profile-connections.js +304 -0
  10. package/modules/hall-ui/public/profile.html +16 -1
  11. package/modules/hall-ui/public/profile.js +5 -0
  12. package/modules/hall-ui/public/style.css +38 -0
  13. package/modules/platform-identity/account-visibility.js +31 -12
  14. package/modules/platform-identity/connections.js +4 -3
  15. package/modules/platform-identity/migrations/platform_identity_019_terms_acceptance.sql +47 -0
  16. package/modules/platform-identity/platform-identity.js +12 -10
  17. package/modules/platform-identity/routes/connections.js +4 -3
  18. package/modules/platform-identity/terms-acceptance.js +97 -0
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/public-landing/terms.html +8 -0
  22. package/scripts/hall-preview/server.js +3 -0
  23. package/src/module-api.js +1 -1
  24. package/tests/connections_api.mjs +13 -8
  25. package/tests/connections_ownership.mjs +6 -1
  26. package/tests/effective_visibility_predicate.mjs +89 -14
  27. package/tests/profile_connections_ui.mjs +889 -0
  28. package/tests/profile_privacy_owner_view.mjs +31 -4
  29. package/tests/profile_route.mjs +15 -8
  30. package/tests/terms_acceptance.mjs +573 -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) {
@@ -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; }
@@ -3,9 +3,10 @@
3
3
  // query cannot drift (ADR 0202 D4; the ADR 0182 `publishableSql` precedent).
4
4
  //
5
5
  // WHAT IT ANSWERS: "may the world see that this account exists at all?" —
6
- // profile_state = 'active' (consent: a hub session has touched the account,
7
- // ADR 0171 D3) AND account_visibility = 'public' (existence: the privacy-spec
8
- // D1 axis). Both floors, ANDed, in one place.
6
+ // CONSENT (profile_state = 'active' AND terms_accepted_at IS NOT NULL: a hub
7
+ // session has touched the account AND that human accepted the platform terms,
8
+ // ADR 0171 D3 + ADR 0213) AND account_visibility = 'public' (existence: the
9
+ // privacy-spec D1 axis). Both floors, ANDed, in one place.
9
10
  //
10
11
  // WHY ITS OWN FILE. ADR 0202 D4 requires connections to consume ONE shared
11
12
  // reader rather than grow a connections-flavoured variant — "adding a second
@@ -52,11 +53,11 @@
52
53
  // (ADR 0192 §6 / ADR 0194 §2).
53
54
  'use strict';
54
55
 
55
- // The JS twin. `account` is any row carrying profile_state + account_visibility;
56
- // anything falsy (absent row, unreadable account) answers false.
56
+ // The JS twin. `account` is any row carrying the consent columns +
57
+ // account_visibility; anything falsy (absent row, unreadable account) answers
58
+ // false. COMPOSED from isAccountActive so the consent axis has one definition.
57
59
  function isAccountVisible(account) {
58
- if (!account) return false;
59
- return account.profile_state === 'active' && account.account_visibility === 'public';
60
+ return isAccountActive(account) && account.account_visibility === 'public';
60
61
  }
61
62
 
62
63
  // The SAME predicate as a SQL fragment, for the reads that filter in the
@@ -70,25 +71,43 @@ function accountVisibleSql(alias) {
70
71
  throw new Error('accountVisibleSql: alias must be a bare SQL identifier');
71
72
  }
72
73
  const q = alias ? `${alias}.` : '';
73
- return `(${q}profile_state = 'active' AND ${q}account_visibility = 'public')`;
74
+ return `(${accountActiveSql(alias)} AND ${q}account_visibility = 'public')`;
74
75
  }
75
76
 
76
- // The CONSENT floor on its own — active, without the existence axis. A private
77
+ // The CONSENT floor on its own — consented, without the existence axis. A private
77
78
  // account is still a real participant in its OWN scope: ADR 0202 D4 §2 says its
78
79
  // connections appear in its own list and in the counterparty's own list, and an
79
80
  // incoming request must name who is asking or it cannot be decided. Those
80
81
  // own-scope reads floor on this; every PUBLIC read floors on accountVisibleSql.
82
+ //
83
+ // CONSENT IS TWO COLUMNS, NOT ONE (ADR 0213, task 1002913). profile_state='active'
84
+ // says the hub witnessed a human at /sso/authorize; terms_accepted_at says that
85
+ // human accepted the platform terms — the artifact ADR 0141 §4 deferred
86
+ // cross-project-aggregation consent to, and which ADR 0171 D3 wrote its own
87
+ // definition around ("the one path where a human is demonstrably at the hub, and
88
+ // where T&C acceptance can later be captured"). Both are stamped by the SAME
89
+ // upsertAccount branch, so they cannot disagree for any account activated after
90
+ // ADR 0213 shipped; requiring both here is what makes that structural rather than
91
+ // a promise, and what keeps a legacy row that predates the terms from publishing
92
+ // on a consent premise that was never true. It re-publishes on its next hub
93
+ // sign-in — the lazy upgrade D3's own backfill rule and P03 already chose.
94
+ //
95
+ // This is the ONE place terms_accepted_at is read. tests/effective_visibility_-
96
+ // predicate.mjs fails any other file that tests it, exactly as for the columns
97
+ // above.
81
98
  function accountActiveSql(alias) {
82
99
  if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
83
100
  throw new Error('accountActiveSql: alias must be a bare SQL identifier');
84
101
  }
85
- return `(${alias ? `${alias}.` : ''}profile_state = 'active')`;
102
+ const q = alias ? `${alias}.` : '';
103
+ return `(${q}profile_state = 'active' AND ${q}terms_accepted_at IS NOT NULL)`;
86
104
  }
87
105
 
88
106
  // The JS twin of accountActiveSql, for an own-scope reader that already holds
89
- // the row (routes/connections.js). Fail-closed, same as isAccountVisible.
107
+ // the row (routes/connections.js). Fail-closed, same as isAccountVisible: a row
108
+ // missing EITHER consent column is not consented.
90
109
  function isAccountActive(account) {
91
- return !!account && account.profile_state === 'active';
110
+ return !!account && account.profile_state === 'active' && account.terms_accepted_at != null;
92
111
  }
93
112
 
94
113
  // -------------------------------------------------------------------------
@@ -23,8 +23,9 @@
23
23
  // (ADR 0171 D4 / ADR 0202 D4).
24
24
  //
25
25
  // WHOSE GRAPH. Node visibility is viewer-relative by design (ADR 0202 title):
26
- // the viewer's OWN connections floor on profile_state='active' alone — a private
27
- // account that accepted you appears in your own-scope list (D4 §2) — while every
26
+ // the viewer's OWN connections floor on the CONSENT axis alone (accountActiveSql:
27
+ // profile_state + terms acceptance, ADR 0213) — a private account that accepted
28
+ // you appears in your own-scope list (D4 §2) — while every
28
29
  // DEEPER hop floors on the full public predicate in account-visibility.js. So a
29
30
  // bridge the viewer may not see yields `null`, not "3rd", and two viewers
30
31
  // legitimately seeing different labels for the same target is the model working.
@@ -128,7 +129,7 @@ async function findAccountByHandle(handle, { pool = defaultPool } = {}) {
128
129
  const norm = String(handle ?? '').trim().toLowerCase();
129
130
  if (!/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(norm)) return null;
130
131
  const { rows } = await pool.query(
131
- `SELECT github_id, handle, display_name, avatar_url, profile_state, account_visibility
132
+ `SELECT github_id, handle, display_name, avatar_url, profile_state, terms_accepted_at, account_visibility
132
133
  FROM platform_identity_accounts
133
134
  WHERE lower(handle) = $1 OR lower(previous_handle) = $1
134
135
  ORDER BY (lower(handle) = $1) DESC
@@ -0,0 +1,47 @@
1
+ -- platform_identity_019_terms_acceptance.sql — the platform T&C acceptance
2
+ -- record (ADR 0213, task 1002913; the artifact ADR 0141 §4 assumed and ADR 0171
3
+ -- D3 reserved a place for).
4
+ --
5
+ -- WHAT IT IS. ADR 0141 deferred consent for CROSS-PROJECT AGGREGATION to "the
6
+ -- platform T&C" and conditioned the scouting view on it; ADR 0171 D3 then made
7
+ -- consent a fail-closed profile_state whose activation moment is "the one path
8
+ -- where a human is demonstrably at the hub (and where T&C acceptance can later
9
+ -- be captured, task 1002913)". This is that capture. terms_accepted_at is the
10
+ -- moment the hub itself witnessed the human accept; terms_version records WHICH
11
+ -- text they accepted, so a later revision is a distinguishable event rather than
12
+ -- a silent overwrite.
13
+ --
14
+ -- WRITTEN ONLY BY THE HUB'S OWN SIGN-IN. Both columns are set exclusively by
15
+ -- upsertAccount's `activate` branch, whose sole caller is /sso/authorize, where
16
+ -- `activate: true` is a source literal and every other field comes from the
17
+ -- hub's resolved session (ADR 0205 D3). No request-body field can reach them —
18
+ -- an acceptance record a caller could assert is not evidence of anything.
19
+ --
20
+ -- IDEMPOTENT BY CONSTRUCTION. The conflict branch writes
21
+ -- COALESCE(existing, now()), so the FIRST acceptance is the one that stands and
22
+ -- signing in again never rewrites or duplicates it.
23
+ --
24
+ -- NO BACKFILL — DELIBERATE, and the same asymmetry ADR 0171 D3 chose. Existing
25
+ -- accounts get NULL: no platform T&C covering cross-project aggregation has ever
26
+ -- been shown to anyone, so no acceptance exists to record, and inventing a
27
+ -- timestamp would fabricate the very artifact this task exists to make real.
28
+ -- They re-accept on their next hub-session authorize, exactly as D3's own
29
+ -- backfill rule provides ("all existing accounts start provisional and lazily
30
+ -- upgrade on their next hub-session authorize") and P03 ratified (task 1002799:
31
+ -- no backfill activation). The consequence — currently-active accounts are not
32
+ -- publicly visible until their next hub sign-in — is the owner decision carried
33
+ -- on blocker 1000122; grandfathering is one line here if the owner prefers it.
34
+ --
35
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
36
+
37
+ BEGIN;
38
+
39
+ ALTER TABLE platform_identity_accounts
40
+ ADD COLUMN IF NOT EXISTS terms_accepted_at timestamptz,
41
+ ADD COLUMN IF NOT EXISTS terms_version text;
42
+
43
+ -- No index: acceptance is read only as part of the per-account effective-
44
+ -- visibility predicate, and accounts are already looked up by github_id via the
45
+ -- UNIQUE constraint from platform_identity_001_tables.sql.
46
+
47
+ COMMIT;
@@ -18,6 +18,7 @@ const api = require('../../src/module-api');
18
18
  // D4 named). Every public read below joins it from here; nothing in this file
19
19
  // re-derives one (see account-visibility.js).
20
20
  const visibility = require('./account-visibility');
21
+ const terms = require('./terms-acceptance');
21
22
 
22
23
  const { pool: defaultPool } = api;
23
24
  const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
@@ -139,10 +140,11 @@ async function ensureAccount({ githubId, githubLogin, displayName, avatarUrl },
139
140
  // github_id). Called at /sso/authorize for the signed-in hub user. Identity only.
140
141
  //
141
142
  // `activate` is the ONE consent upgrade path (ADR 0171 D3): an account becomes
142
- // profile_state='active' iff touched by a hub-session /sso/authorize — the path
143
- // where a human is demonstrably at the hub. Every other caller (device-poll,
144
- // membership check-in) leaves the fail-closed 'provisional' default; activation
145
- // is one-way (a later non-activating upsert never demotes an active account).
143
+ // profile_state='active' iff touched by a hub-session /sso/authorize — where a
144
+ // human is demonstrably at the hub — and the SAME branch stamps the platform T&C
145
+ // acceptance (ADR 0213, idempotent; ./terms-acceptance.js), so no account is
146
+ // active without one. Other callers (device-poll, check-in) leave the fail-closed
147
+ // 'provisional' + a NULL acceptance; activation is one-way (never demotes).
146
148
  //
147
149
  // display_name is source-guarded (the ADR 0156 mirror, P02/task 1002798): once
148
150
  // a builder sets the account name themselves (setAccountProfileFields stamps
@@ -154,18 +156,18 @@ async function ensureAccount({ githubId, githubLogin, displayName, avatarUrl },
154
156
  // rewrites identity; a federated check-in gets `ensureAccount`.
155
157
  async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate = false }, { pool = defaultPool } = {}) {
156
158
  const { rows } = await pool.query(
157
- `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
158
- VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END)
159
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state${terms.acceptanceInsertColumns()})
160
+ VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END${terms.acceptanceInsertValues('$5', '$6')})
159
161
  ON CONFLICT (github_id) DO UPDATE SET
160
162
  github_login = EXCLUDED.github_login,
161
163
  display_name = CASE WHEN platform_identity_accounts.display_name_source = 'user'
162
164
  THEN platform_identity_accounts.display_name
163
165
  ELSE EXCLUDED.display_name END,
164
166
  avatar_url = EXCLUDED.avatar_url,
165
- profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
167
+ profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END, ${terms.acceptanceConflictSet('$5', '$6')}
166
168
  updated_at = now()
167
169
  RETURNING id, github_id, github_login, display_name, avatar_url, profile_state, handle`,
168
- [githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl), !!activate],
170
+ [githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl), !!activate, terms.TERMS_VERSION],
169
171
  );
170
172
  const row = rows[0];
171
173
  // Seed the handle on first sight (spec Decision 3); a no-op once set.
@@ -189,7 +191,7 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
189
191
  async function getAccount(githubId, { pool = defaultPool } = {}) {
190
192
  const { rows } = await pool.query(
191
193
  `SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats,
192
- account_visibility, recruiter_discoverable,
194
+ terms_accepted_at, terms_version, account_visibility, recruiter_discoverable,
193
195
  handle, previous_handle, handle_changed_at, bio, links, display_name_source,
194
196
  created_at, updated_at
195
197
  FROM platform_identity_accounts WHERE github_id = $1`,
@@ -861,7 +863,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
861
863
  async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
862
864
  if (!isNumericGithubId(githubId)) return null;
863
865
  const { rows } = await pool.query(
864
- `SELECT profile_state, account_visibility, hide_stats
866
+ `SELECT profile_state, terms_accepted_at, account_visibility, hide_stats
865
867
  FROM platform_identity_accounts WHERE github_id = $1`,
866
868
  [githubId],
867
869
  );
@@ -55,9 +55,10 @@ function connectionId(req) {
55
55
  }
56
56
 
57
57
  // The caller's platform account, or a { fail } describing why they cannot take
58
- // part. profile_state = 'active' is the floor for the WHOLE feature (ADR 0202
59
- // D4 §1): a provisional account cannot send, cannot receive, and never appears
60
- // in a list or a frontier. This is about the CALLER, so it may say so plainly.
58
+ // part. The CONSENT axis (isAccountActive — active AND the platform terms
59
+ // accepted, ADR 0213) is the floor for the WHOLE feature (ADR 0202 D4 §1): a
60
+ // provisional account cannot send, cannot receive, and never appears in a list
61
+ // or a frontier. This is about the CALLER, so it may say so plainly.
61
62
  async function actingAccount(builder) {
62
63
  const githubId = builder && builder.github_id;
63
64
  if (!pi.isNumericGithubId(githubId)) {