@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,573 @@
1
+ // tests/terms_acceptance.mjs
2
+ //
3
+ // THE PLATFORM T&C ACCEPTANCE RECORD — "the hub's own sign-in is the only thing
4
+ // that can say a human accepted the platform terms, it says so once, and nothing
5
+ // publishes without it" (ADR 0213, task 1002913; the artifact ADR 0141 §4
6
+ // deferred cross-project-aggregation consent to and ADR 0171 D3 reserved a place
7
+ // for).
8
+ //
9
+ // WHY THIS EXISTS. An acceptance record is only worth the evidence behind it. If
10
+ // a caller can assert one — a body field, a query param, a header — then the
11
+ // record says nothing except "somebody claimed this", and the consent premise the
12
+ // whole public-by-default profile rests on ("tied to having a platform account,
13
+ // which already carries platform T&C consent", public-profiles spec) is a
14
+ // fiction. So the property under test is not "acceptance is stored" but
15
+ // "acceptance CANNOT BE FORGED, and cannot be silently restamped".
16
+ //
17
+ // THE MECHANISM — four things, each of which can genuinely fail here:
18
+ //
19
+ // 1. THE PAGE. /terms carries the cross-project section, and it is apex-only:
20
+ // a claim about the platform must not ship inside a child project's page
21
+ // (task 1002748). Run through the real stripApexOnly, not asserted by eye.
22
+ //
23
+ // 2. THE CAPTURE POINT. The acceptance columns are written by exactly one
24
+ // statement, reached from exactly one route — /sso/authorize, where
25
+ // `activate: true` is a SOURCE LITERAL and every other field comes from the
26
+ // hub's own resolved session (the ADR 0205 D3 property task 1003340
27
+ // established for recordFederatedSignin). The test drives the REAL router
28
+ // over real HTTP with an attacker-controlled query string and body, and
29
+ // proves neither reaches the statement.
30
+ //
31
+ // 3. IDEMPOTENCE, EXECUTED. The fake pool below is a real little
32
+ // platform_identity_accounts table plus an interpreter that reads the
33
+ // upsert's OWN column assignments and applies them. So COALESCE is not
34
+ // asserted against a string: signing in twice actually runs the conflict
35
+ // branch, and if the COALESCE were dropped the second sign-in would really
36
+ // overwrite the first timestamp and the assertion would really fail.
37
+ // The interpreter REFUSES an assignment shape it does not recognise rather
38
+ // than skipping it — an unread assignment must never read as "unchanged"
39
+ // (the tests/own_session_revoke.mjs / effective_visibility_predicate.mjs
40
+ // pattern: a filter this thing cannot read must never degrade to no filter).
41
+ //
42
+ // 4. THE ADR 0171 D3 TIE. Consent is now two columns, and the shared predicate
43
+ // is what enforces it. An account the hub witnessed but that never accepted
44
+ // is not consented — checked through the real account-visibility.js, which
45
+ // is the only file allowed to answer that question.
46
+ //
47
+ // DB-free by construction: static HTML, a fake pool, and an ephemeral loopback
48
+ // listener. It therefore runs in the lane that actually EXECUTES — see the lane
49
+ // proof at the bottom, which exists because three tests (hub_profile_leak,
50
+ // handle_machinery, profile_fields) declared themselves integration, were in
51
+ // NEITHER lane, and asserted nothing for months (task 1002806, learning 1000218).
52
+ //
53
+ // Run: node tests/terms_acceptance.mjs
54
+
55
+ import { strict as assert } from 'node:assert';
56
+ import fs from 'node:fs';
57
+ import path from 'node:path';
58
+ import http from 'node:http';
59
+ import express from 'express';
60
+ import { createRequire } from 'node:module';
61
+ import { fileURLToPath } from 'node:url';
62
+
63
+ // ESM import so knip credits the exports (a createRequire import reads as
64
+ // unused — the repo's standing blind spot, learning: knip-createrequire).
65
+ import {
66
+ isAccountActive,
67
+ isAccountVisible,
68
+ accountActiveSql,
69
+ } from '../modules/platform-identity/account-visibility.js';
70
+ import {
71
+ TERMS_VERSION,
72
+ acceptanceInsertColumns,
73
+ acceptanceInsertValues,
74
+ acceptanceConflictSet,
75
+ } from '../modules/platform-identity/terms-acceptance.js';
76
+
77
+ const require = createRequire(import.meta.url);
78
+ const repo = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
79
+
80
+ let passed = 0;
81
+ let failed = 0;
82
+ function t(name, fn) {
83
+ try { fn(); passed++; console.log(` PASS ${name}`); }
84
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
85
+ }
86
+ async function ta(name, fn) {
87
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
88
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
89
+ }
90
+
91
+ // ===========================================================================
92
+ // 1. The page — the consent text exists, and it is a platform-only claim
93
+ // ===========================================================================
94
+
95
+ console.log('the page (/terms carries the consent the ADR deferred to it):');
96
+
97
+ const termsHtml = fs.readFileSync(path.join(repo, 'public-landing', 'terms.html'), 'utf8');
98
+ const { stripApexOnly } = require('../src/platform-server.js');
99
+
100
+ t('the terms page states what crosses between projects', () => {
101
+ assert.match(termsHtml, /Your work across projects/,
102
+ 'the section ADR 0141 §4 deferred consent to is missing from the page');
103
+ // The four things the wording must carry for the mechanism to be honest.
104
+ assert.match(termsHtml, /karma/i, 'the data that crosses must be named, not gestured at');
105
+ assert.match(termsHtml, /descriptive analytics and never authority/i,
106
+ 'ADR 0016: a cross-project number describes, it never governs — this is the load-bearing sentence');
107
+ assert.match(termsHtml, /public by default/i, 'what becoming public means must be stated');
108
+ assert.match(termsHtml, /hide your statistics/i, 'and the opt-outs that exist');
109
+ });
110
+
111
+ t('the acceptance the page promises is the one the code actually keeps', () => {
112
+ assert.match(termsHtml, /stores the date you accepted these terms/i,
113
+ 'the page must tell the reader an acceptance record is kept');
114
+ assert.match(termsHtml, /signing in again does not change the original date/i,
115
+ 'and that it is written once — the property the COALESCE below actually provides');
116
+ });
117
+
118
+ t('the cross-project claim is apex-only — it never ships in a child project page', () => {
119
+ const stripped = stripApexOnly(termsHtml);
120
+ assert.doesNotMatch(stripped, /Your work across projects/,
121
+ 'a claim about THE PLATFORM survived into a child project page (task 1002748)');
122
+ // ...and the stripper did not simply eat the page.
123
+ assert.match(stripped, /Fair use/, 'the portable spine must survive stripping');
124
+ assert.match(stripped, /Your projects and your code/);
125
+ });
126
+
127
+ // ===========================================================================
128
+ // 2. The SQL fragments — shape, and the guard against a smuggled placeholder
129
+ // ===========================================================================
130
+
131
+ console.log('\nthe fragments (the write side, in one place):');
132
+
133
+ t('the acceptance fragments refuse anything that is not a Postgres placeholder', () => {
134
+ for (const bad of ['5', '$0', 'x', "$5; DROP TABLE platform_identity_accounts --", '', null]) {
135
+ assert.throws(() => acceptanceInsertValues(bad, '$6'), /Postgres placeholder/,
136
+ `insert values accepted ${JSON.stringify(bad)}`);
137
+ assert.throws(() => acceptanceConflictSet('$5', bad), /Postgres placeholder/,
138
+ `conflict set accepted ${JSON.stringify(bad)}`);
139
+ }
140
+ });
141
+
142
+ t('the conflict fragment is COALESCE-shaped — the first acceptance is the one that stands', () => {
143
+ const set = acceptanceConflictSet('$5', '$6');
144
+ assert.match(set, /COALESCE\(platform_identity_accounts\.terms_accepted_at, now\(\)\)/,
145
+ 'without COALESCE every sign-in restamps, and the record stops meaning "when you accepted"');
146
+ assert.match(set, /COALESCE\(platform_identity_accounts\.terms_version, \$6::text\)/);
147
+ assert.match(set, /ELSE platform_identity_accounts\.terms_accepted_at END/,
148
+ 'a NON-activating upsert must leave the record exactly as it was');
149
+ });
150
+
151
+ t('the insert fragment writes NULL for a caller the hub did not witness', () => {
152
+ const values = acceptanceInsertValues('$5', '$6');
153
+ assert.match(values, /CASE WHEN \$5::boolean THEN now\(\) ELSE NULL END/,
154
+ 'a check-in / device-poll account has no acceptance — fail-closed');
155
+ assert.equal(acceptanceInsertColumns(), ', terms_accepted_at, terms_version');
156
+ });
157
+
158
+ // ===========================================================================
159
+ // The fake pool: a real little accounts table + an assignment interpreter
160
+ // ===========================================================================
161
+
162
+ // Split a SQL list on top-level commas — depth-aware, so a CASE expression or a
163
+ // COALESCE(a, b) is ONE element rather than two.
164
+ function splitTopLevel(s) {
165
+ const out = [];
166
+ let depth = 0;
167
+ let caseDepth = 0;
168
+ let buf = '';
169
+ const tokens = s.split(/(\(|\)|,|\bCASE\b|\bEND\b)/i);
170
+ for (const tok of tokens) {
171
+ if (tok === '(') depth++;
172
+ else if (tok === ')') depth--;
173
+ else if (/^CASE$/i.test(tok)) caseDepth++;
174
+ else if (/^END$/i.test(tok)) caseDepth--;
175
+ if (tok === ',' && depth === 0 && caseDepth === 0) { out.push(buf.trim()); buf = ''; continue; }
176
+ buf += tok;
177
+ }
178
+ if (buf.trim()) out.push(buf.trim());
179
+ return out;
180
+ }
181
+
182
+ // The value a recognised acceptance assignment produces. Deliberately NOT a
183
+ // permissive `.*?` match: every shape this understands is spelled out, and an
184
+ // unrecognised one THROWS. A SET list matched loosely is how a deleted audit
185
+ // trail passes a test that looks like it is checking one.
186
+ function evalAcceptance(expr, { col, activate, existing, params, now }) {
187
+ const self = `platform_identity_accounts.${col}`;
188
+ const src = (tok) => {
189
+ if (/^now\(\)$/i.test(tok)) return now;
190
+ const m = /^\$(\d+)::text$/.exec(tok);
191
+ if (m) return params[Number(m[1]) - 1];
192
+ throw new Error(`unrecognised source expression "${tok}" for ${col}`);
193
+ };
194
+ const esc = self.replace(/[.]/g, '\\.');
195
+ let m;
196
+
197
+ // The shipped, idempotent shape: activate ? (existing ?? src) : existing
198
+ m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean THEN COALESCE\\(${esc}, (.+?)\\) ELSE ${esc} END$`, 'i').exec(expr);
199
+ if (m) return activate ? (existing != null ? existing : src(m[2])) : existing;
200
+
201
+ // The overwriting shape — a real possibility, and the one the idempotence
202
+ // assertion below must be able to tell apart from the shipped one.
203
+ m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean THEN (.+?) ELSE ${esc} END$`, 'i').exec(expr);
204
+ if (m) return activate ? src(m[2]) : existing;
205
+
206
+ // The INSERT-branch shape: activate ? src : NULL
207
+ m = /^CASE WHEN \$(\d+)::boolean THEN (.+?) ELSE NULL END$/i.exec(expr);
208
+ if (m) return activate ? src(m[2]) : null;
209
+
210
+ // Unconditional — a caller-independent write. Recognised so the "non-activating
211
+ // paths never stamp" assertion can catch it rather than erroring out.
212
+ if (/^(now\(\)|\$\d+::text)$/i.test(expr)) return src(expr);
213
+
214
+ throw new Error(
215
+ `fake pool: unrecognised assignment for ${col}: "${expr}" — an assignment this interpreter `
216
+ + 'cannot read must never be treated as "leaves the column alone"');
217
+ }
218
+
219
+ const NOW = '2026-08-29T12:00:00.000Z';
220
+ const LATER = '2026-08-30T09:30:00.000Z';
221
+
222
+ function makePool() {
223
+ const accounts = new Map(); // github_id -> row
224
+ const calls = [];
225
+ let clock = NOW;
226
+ const pool = {
227
+ setClock(v) { clock = v; },
228
+ accounts,
229
+ calls,
230
+ query: async (text, params = []) => {
231
+ const sql = String(text).replace(/\s+/g, ' ').trim();
232
+ calls.push({ sql, params });
233
+
234
+ if (/^SELECT .* FROM platform_identity_sso_clients/i.test(sql)) {
235
+ return { rows: [{ client_id: 'proj-a', status: 'active', redirect_uris: ['https://proj-a.example/callback'] }], rowCount: 1 };
236
+ }
237
+
238
+ // The account upsert — the statement under test.
239
+ if (/^INSERT INTO platform_identity_accounts/i.test(sql)) {
240
+ const cols = splitTopLevel(/INSERT INTO platform_identity_accounts \(([^)]+)\)/i.exec(sql)[1]).map((c) => c.trim());
241
+ const id = String(params[0]);
242
+ const existing = accounts.get(id);
243
+
244
+ if (/ON CONFLICT \(github_id\) DO NOTHING/i.test(sql)) {
245
+ if (!existing) accounts.set(id, { github_id: id, github_login: params[1], profile_state: 'provisional', terms_accepted_at: null, terms_version: null, account_visibility: 'public', hide_stats: false, handle: 'h' });
246
+ return { rows: [], rowCount: existing ? 0 : 1 };
247
+ }
248
+
249
+ const valuesRaw = /VALUES \((.+?)\) ON CONFLICT/i.exec(sql);
250
+ assert.ok(valuesRaw, `the upsert changed shape — update the interpreter, do not bypass it: ${sql.slice(0, 200)}`);
251
+ const values = splitTopLevel(valuesRaw[1]);
252
+ assert.equal(values.length, cols.length, 'the INSERT column list and VALUES list must line up');
253
+
254
+ const activate = params[4] === true;
255
+ let row;
256
+ if (!existing) {
257
+ row = { github_id: id, github_login: params[1], account_visibility: 'public', hide_stats: false, handle: 'h' };
258
+ for (const col of ['terms_accepted_at', 'terms_version']) {
259
+ const i = cols.indexOf(col);
260
+ assert.notEqual(i, -1, `the upsert stopped writing ${col} on insert`);
261
+ row[col] = evalAcceptance(values[i], { col, activate, existing: null, params, now: clock });
262
+ }
263
+ row.profile_state = activate ? 'active' : 'provisional';
264
+ accounts.set(id, row);
265
+ } else {
266
+ row = existing;
267
+ const setRaw = /DO UPDATE SET (.+?) RETURNING/i.exec(sql);
268
+ assert.ok(setRaw, 'the conflict branch changed shape — update the interpreter');
269
+ const assigns = new Map();
270
+ for (const a of splitTopLevel(setRaw[1])) {
271
+ const eq = a.indexOf('=');
272
+ assigns.set(a.slice(0, eq).trim(), a.slice(eq + 1).trim());
273
+ }
274
+ for (const col of ['terms_accepted_at', 'terms_version']) {
275
+ assert.ok(assigns.has(col), `the conflict branch stopped writing ${col} — an account that signs in again would never record its acceptance`);
276
+ row[col] = evalAcceptance(assigns.get(col), { col, activate, existing: row[col], params, now: clock });
277
+ }
278
+ if (activate) row.profile_state = 'active';
279
+ }
280
+ return { rows: [{ ...row }], rowCount: 1 };
281
+ }
282
+
283
+ if (/^SELECT .* FROM platform_identity_accounts/i.test(sql)) {
284
+ const row = accounts.get(String(params[0]));
285
+ return { rows: row ? [{ ...row }] : [], rowCount: row ? 1 : 0 };
286
+ }
287
+
288
+ // Handle seeding, auth-code minting, and anything else the route does.
289
+ if (/platform_identity_auth_codes/i.test(sql)) return { rows: [{ code: 'CODE' }], rowCount: 1 };
290
+ return { rows: [], rowCount: 0 };
291
+ },
292
+ };
293
+ return pool;
294
+ }
295
+
296
+ // ---- the real SSO router over the real domain module, core pinned ----------
297
+
298
+ const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
299
+ const moduleApiPath = require.resolve('../src/module-api.js');
300
+ const helpers = require('../src/bongos/routes/_helpers.js');
301
+ const pool = makePool();
302
+ let SESSION = null;
303
+
304
+ require.cache[moduleApiPath] = {
305
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
306
+ exports: {
307
+ pool,
308
+ validateOrRespond: helpers.validateOrRespond,
309
+ // The hub's OWN session resolver. It reads the request only to find a
310
+ // session; nothing a caller sends can change what it returns here.
311
+ resolveSession: async () => SESSION,
312
+ logger: () => noopLog,
313
+ hasProvider: () => true,
314
+ registerProvider: () => {},
315
+ on: () => () => {},
316
+ resolveEnv: () => null,
317
+ userAgent: () => 'cloud-bongos-tests',
318
+ LIVE_RANK_LADDER: ['xenos', 'thetes', 'metic', 'archon'],
319
+ },
320
+ };
321
+
322
+ const buildSsoRoutes = require('../modules/platform-identity/routes/sso.js');
323
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
324
+
325
+ const app = express();
326
+ app.use(express.json());
327
+ app.use(attachFail);
328
+ app.use(buildSsoRoutes());
329
+ const server = http.createServer(app);
330
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
331
+ const port = server.address().port;
332
+
333
+ // Raw http.request, not fetch: fetch's keep-alive sockets outlive the listener
334
+ // and closing it under them trips a libuv assert on Windows, which exits the
335
+ // process non-zero with every assertion green — a test that reports success and
336
+ // fails the lane. This is the tests/effective_visibility_predicate.mjs shape.
337
+ function request(path_) {
338
+ return new Promise((resolve, reject) => {
339
+ const req = http.request({ host: '127.0.0.1', port, method: 'GET', path: path_ }, (res) => {
340
+ const chunks = [];
341
+ res.on('data', (c) => chunks.push(c));
342
+ res.on('end', () => resolve({
343
+ status: res.statusCode,
344
+ location: res.headers.location || '',
345
+ text: Buffer.concat(chunks).toString('utf8'),
346
+ }));
347
+ });
348
+ req.on('error', reject);
349
+ req.end();
350
+ });
351
+ }
352
+
353
+ const HUB_SESSION = {
354
+ github_id: '4242', github_login: 'octo', display_name: 'Octo',
355
+ avatar_url: null, status: 'active', source: 'web', system_role: null,
356
+ };
357
+ const REDIRECT = 'https://proj-a.example/callback';
358
+ const authorize = (qs = '') => request(
359
+ `/sso/authorize?client_id=proj-a&redirect_uri=${encodeURIComponent(REDIRECT)}${qs}`,
360
+ );
361
+
362
+ // ===========================================================================
363
+ // 3. The capture point — the hub's own sign-in, and nothing else
364
+ // ===========================================================================
365
+
366
+ console.log('\nthe capture point (only the hub can say a human accepted):');
367
+
368
+ await ta('CONTROL: a hub-session authorize records the acceptance', async () => {
369
+ SESSION = HUB_SESSION;
370
+ pool.accounts.clear();
371
+ pool.setClock(NOW);
372
+ const res = await authorize();
373
+ assert.equal(res.status, 302, 'the authorize leg should hand back a code');
374
+ const row = pool.accounts.get('4242');
375
+ assert.ok(row, 'no account was written at all — the rest of this section would pass vacuously');
376
+ assert.equal(row.terms_accepted_at, NOW, 'the acceptance was not stamped by the hub sign-in');
377
+ assert.equal(row.terms_version, TERMS_VERSION, 'the record must name WHICH text was accepted');
378
+ assert.equal(row.profile_state, 'active');
379
+ });
380
+
381
+ await ta('the acceptance cannot be forged from the query string or the body', async () => {
382
+ SESSION = HUB_SESSION;
383
+ pool.accounts.clear();
384
+ pool.calls.length = 0;
385
+ pool.setClock(NOW);
386
+ const forged = '1999-01-01T00:00:00.000Z';
387
+ // Everything an attacker can reach: query params named exactly like the
388
+ // columns and the flag, plus a body on the same route.
389
+ const res = await request(
390
+ `/sso/authorize?client_id=proj-a&redirect_uri=${encodeURIComponent(REDIRECT)}`
391
+ + `&terms_accepted_at=${encodeURIComponent(forged)}&terms_version=FORGED&activate=true`
392
+ + '&profile_state=active',
393
+ );
394
+ assert.equal(res.status, 302);
395
+ const row = pool.accounts.get('4242');
396
+ assert.equal(row.terms_accepted_at, NOW, 'a caller-supplied timestamp reached the acceptance record');
397
+ assert.equal(row.terms_version, TERMS_VERSION, 'a caller-supplied version reached the acceptance record');
398
+
399
+ // And structurally: the forged values are nowhere in the statement's params,
400
+ // so there is no path by which a later refactor could start reading them.
401
+ const upserts = pool.calls.filter((c) => /INSERT INTO platform_identity_accounts/i.test(c.sql) && /DO UPDATE/i.test(c.sql));
402
+ assert.equal(upserts.length, 1, `expected exactly one account upsert, got ${upserts.length}`);
403
+ const flat = JSON.stringify(upserts[0].params);
404
+ assert.doesNotMatch(flat, /FORGED/, 'a caller-controlled value was bound into the acceptance statement');
405
+ assert.doesNotMatch(flat, /1999/, 'a caller-controlled timestamp was bound into the acceptance statement');
406
+ assert.equal(upserts[0].params.length, 6,
407
+ 'the statement grew a parameter — every one of them must come from the hub session or a constant');
408
+ assert.equal(upserts[0].params[5], TERMS_VERSION, 'the version parameter must be the server constant');
409
+ assert.equal(upserts[0].params[4], true, 'activate must be the literal true the route passes, not a parsed input');
410
+ });
411
+
412
+ await ta('no hub session, no acceptance — the unauthenticated caller is bounced, not recorded', async () => {
413
+ SESSION = null;
414
+ pool.accounts.clear();
415
+ const res = await authorize();
416
+ assert.equal(res.status, 302);
417
+ assert.match(res.location, /\/auth\/web\/start/,
418
+ 'an anonymous caller must be sent to the hub sign-in');
419
+ assert.equal(pool.accounts.size, 0, 'an account (and an acceptance) was created for a caller with no session');
420
+ });
421
+
422
+ await ta('a dev-box session cannot mint an acceptance either (ADR 0016 trust boundary)', async () => {
423
+ SESSION = { ...HUB_SESSION, source: 'box' };
424
+ pool.accounts.clear();
425
+ const res = await authorize();
426
+ assert.equal(res.status, 403);
427
+ assert.equal(pool.accounts.size, 0, 'a box-scoped session recorded a platform T&C acceptance');
428
+ });
429
+
430
+ // ===========================================================================
431
+ // 4. Idempotence — executed, not asserted against a string
432
+ // ===========================================================================
433
+
434
+ console.log('\nidempotence (the first acceptance is the one that stands):');
435
+
436
+ await ta('signing in twice does not rewrite or duplicate the acceptance', async () => {
437
+ SESSION = HUB_SESSION;
438
+ pool.accounts.clear();
439
+ pool.setClock(NOW);
440
+ await authorize();
441
+ const first = pool.accounts.get('4242').terms_accepted_at;
442
+ assert.equal(first, NOW);
443
+
444
+ // A second sign-in, a day later. This really runs the ON CONFLICT branch.
445
+ pool.setClock(LATER);
446
+ await authorize();
447
+ const row = pool.accounts.get('4242');
448
+ assert.equal(row.terms_accepted_at, NOW,
449
+ `the second sign-in restamped the acceptance (${row.terms_accepted_at}) — the record no longer says when consent was actually given`);
450
+ assert.equal(row.terms_version, TERMS_VERSION);
451
+ assert.equal(pool.accounts.size, 1, 'a second row was created — one account, one acceptance');
452
+ });
453
+
454
+ await ta('a NON-activating write never touches an existing acceptance', async () => {
455
+ SESSION = HUB_SESSION;
456
+ pool.accounts.clear();
457
+ pool.setClock(NOW);
458
+ await authorize();
459
+
460
+ // ensureAccount is the federated path (check-in / device-poll): it must not
461
+ // create, restamp, or clear an acceptance.
462
+ const pi = require('../modules/platform-identity/platform-identity.js');
463
+ pool.setClock(LATER);
464
+ await pi.ensureAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
465
+ assert.equal(pool.accounts.get('4242').terms_accepted_at, NOW,
466
+ 'a federated check-in changed the acceptance record');
467
+ });
468
+
469
+ await ta('a federated check-in creates an account with NO acceptance at all', async () => {
470
+ pool.accounts.clear();
471
+ pool.setClock(NOW);
472
+ const pi = require('../modules/platform-identity/platform-identity.js');
473
+ await pi.ensureAccount({ githubId: '9999', githubLogin: 'newbie', displayName: null, avatarUrl: null }, { pool });
474
+ const row = pool.accounts.get('9999');
475
+ assert.ok(row, 'the check-in should still create the provisional account shell');
476
+ assert.equal(row.terms_accepted_at, null,
477
+ 'an account created by a project check-in has never seen the platform terms — it must not carry an acceptance');
478
+ assert.equal(row.profile_state, 'provisional');
479
+ });
480
+
481
+ server.close();
482
+
483
+ // ===========================================================================
484
+ // 5. The ADR 0171 D3 tie — consent is two columns, in the one shared predicate
485
+ // ===========================================================================
486
+
487
+ console.log('\nthe D3 tie (nothing publishes on a consent premise that was never true):');
488
+
489
+ t('the shared consent predicate requires the acceptance, in both forms', () => {
490
+ const witnessedAndAccepted = { profile_state: 'active', terms_accepted_at: NOW, account_visibility: 'public' };
491
+ const witnessedOnly = { profile_state: 'active', terms_accepted_at: null, account_visibility: 'public' };
492
+ const acceptedOnly = { profile_state: 'provisional', terms_accepted_at: NOW, account_visibility: 'public' };
493
+
494
+ assert.equal(isAccountActive(witnessedAndAccepted), true);
495
+ assert.equal(isAccountActive(witnessedOnly), false,
496
+ 'an account the hub witnessed before the terms existed is NOT consented (ADR 0213)');
497
+ assert.equal(isAccountActive(acceptedOnly), false,
498
+ 'and an acceptance alone never substitutes for the hub having witnessed the human (D3)');
499
+ assert.equal(isAccountVisible(witnessedOnly), false, 'so it is not publicly addressable either');
500
+
501
+ assert.match(accountActiveSql(), /terms_accepted_at IS NOT NULL/,
502
+ 'the SQL twin must ask the same question as the JS twin');
503
+ assert.match(accountActiveSql('pa'), /pa\.terms_accepted_at IS NOT NULL/, 'and qualify it under an alias');
504
+ });
505
+
506
+ t('the page and the code agree on which version is current', () => {
507
+ assert.match(termsHtml, new RegExp(TERMS_VERSION.replace(/-/g, '\\-')),
508
+ `the page must state the version the code stamps (${TERMS_VERSION}) — a record naming a text the reader `
509
+ + 'cannot find is not evidence of anything');
510
+ });
511
+
512
+ // ===========================================================================
513
+ // 6. The projection rule — a twin cannot judge a column it was not given
514
+ // ===========================================================================
515
+
516
+ // The SQL predicate reads the column straight off the table, so it is safe. The
517
+ // JS twins are not: they judge a ROW, and a row is only as good as its SELECT
518
+ // list. Drop terms_accepted_at from a projection and isAccountActive() answers
519
+ // false for EVERYONE holding that row — connections denies every builder, the
520
+ // profile port returns null for every account — silently, with every other test
521
+ // still green. That is a deny-all outage dressed as a privacy feature, and it is
522
+ // exactly the failure a mutation found here.
523
+ //
524
+ // So: every read whose row is handed to a JS twin must project both consent
525
+ // columns. Enumerated from source, not from a list someone maintains by hand.
526
+
527
+ console.log('\nthe projection rule (every twin-fed read carries the consent columns):');
528
+
529
+ const TWIN_FED_READS = [
530
+ { file: 'modules/platform-identity/platform-identity.js', fn: 'getAccount',
531
+ why: 'routes/connections.js actingAccount() calls isAccountActive on this row' },
532
+ { file: 'modules/platform-identity/platform-identity.js', fn: 'getPublicProfileExtras',
533
+ why: 'it calls isRollupVisible on this row before returning any numbers' },
534
+ { file: 'modules/platform-identity/connections.js', fn: 'findAccountByHandle',
535
+ why: 'routes/connections.js visibleTarget() calls isAccountVisible/isAccountActive on this row' },
536
+ ];
537
+
538
+ for (const { file, fn, why } of TWIN_FED_READS) {
539
+ t(`${file.split('/').pop()} ${fn}() projects the consent columns — ${why}`, () => {
540
+ const code = fs.readFileSync(path.join(repo, file), 'utf8');
541
+ const at = code.indexOf(`function ${fn}(`);
542
+ assert.notEqual(at, -1, `${fn} no longer exists in ${file} — this rule now guards nothing`);
543
+ // The function's own body, to its closing brace at column 0.
544
+ const end = code.indexOf('\n}', at);
545
+ assert.notEqual(end, -1, `could not bound ${fn}`);
546
+ const body = code.slice(at, end);
547
+ assert.match(body, /SELECT/i, `${fn} no longer issues a SELECT — update this rule rather than deleting it`);
548
+ assert.match(body, /\bterms_accepted_at\b/,
549
+ `${fn} selects the row a JS consent twin judges, but does NOT project terms_accepted_at. `
550
+ + 'The twin reads undefined, answers false for every account, and the surface denies everyone '
551
+ + 'with no error anywhere (ADR 0213).');
552
+ assert.match(body, /\bprofile_state\b/,
553
+ `${fn} does not project profile_state — the other half of the same answer`);
554
+ });
555
+ }
556
+
557
+ // ===========================================================================
558
+ // 7. Lane proof
559
+ // ===========================================================================
560
+
561
+ console.log('\ncoverage:');
562
+
563
+ t('this proof runs in the DB-free unit lane (it is not parked as integration or known-failing)', () => {
564
+ const runner = fs.readFileSync(path.join(repo, 'scripts', 'gds', 'run-unit-tests.js'), 'utf8');
565
+ assert.ok(
566
+ !/terms_acceptance/.test(runner),
567
+ 'terms_acceptance is named in run-unit-tests.js — parked into INTEGRATION or KNOWN_FAILING it would gate '
568
+ + 'nothing, which is exactly how hub_profile_leak / handle_machinery / profile_fields asserted nothing for '
569
+ + 'months (task 1002806, learning 1000218)');
570
+ });
571
+
572
+ console.log(`\n${passed} passed, ${failed} failed`);
573
+ process.exit(failed === 0 ? 0 : 1);