@bongos/core 1.19.1023 → 1.19.1025

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 (37) hide show
  1. package/.bongos-core.json +55 -40
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +2 -0
  8. package/docs/api/openapi.json +58 -4
  9. package/docs/api-reference.md +4 -3
  10. package/docs/copy-inventory.md +18 -16
  11. package/docs/copy-registry.json +43 -25
  12. package/docs/module-api-changelog.md +4 -0
  13. package/migrations/core_255_access_requests_invite_kind.sql +52 -0
  14. package/modules/government/catalog.js +1 -1
  15. package/modules/hall-ui/public/people.html +1 -1
  16. package/modules/onboarding/routes/access-requests.js +62 -11
  17. package/modules/platform-identity/connection-suggestions.js +1 -1
  18. package/modules/platform-identity/migrations/platform_identity_024_terms_grandfathered.sql +68 -0
  19. package/modules/platform-identity/terms-acceptance.js +34 -14
  20. package/modules/public-landing/public/terms.html +2 -1
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/scripts/gds/run-unit-tests.js +5 -0
  24. package/src/bongos/auth-github.js +4 -2
  25. package/src/bongos/db-kernel.js +1 -1
  26. package/src/module-api.js +1 -1
  27. package/tests/access_requests_invite.mjs +81 -4
  28. package/tests/application_lifecycle.mjs +174 -6
  29. package/tests/auth_inactive_builder.mjs +84 -0
  30. package/tests/connection_suggestions.mjs +1 -1
  31. package/tests/connections_api.mjs +1 -1
  32. package/tests/handle_machinery.mjs +2 -0
  33. package/tests/people_name_only.mjs +2 -1
  34. package/tests/profile_connections_ui.mjs +2 -2
  35. package/tests/profile_rollup_consent.mjs +3 -0
  36. package/tests/terms_acceptance.mjs +264 -14
  37. package/tests/terms_grandfather_db.mjs +144 -0
@@ -14,6 +14,9 @@
14
14
  // so a Cookie header stuffed with dozens of gds_session=<junk> values can't
15
15
  // amplify into dozens of per-request DB lookups (DoS).
16
16
  //
17
+ // 3. An offboarded builder's GitHub web sign-in must not re-seed the
18
+ // github_oauth_tokens row deactivateBuilder deleted (task 1004245, ADR 0332).
19
+ //
17
20
  // lookupSession is stubbed on the db module BEFORE auth.js is required, because
18
21
  // auth.js destructures it at load time. No server/DB/network.
19
22
  //
@@ -90,5 +93,86 @@ await t('extractTokens caps the candidates it returns (DoS amplification guard)'
90
93
  assert.equal(tokens[0], 'realone', 'the Bearer token is tried first');
91
94
  });
92
95
 
96
+ // ---- web sign-in cannot re-seed an offboarded builder's GitHub token --------
97
+ // The admission gate admits every EXISTING builder, so an inactive one still
98
+ // completes the web flow. The fake DB runs the REAL upsertBuilder SQL and answers
99
+ // only the columns its RETURNING list names, so this fails if `status` ever drops
100
+ // out of that list. GitHub is a stubbed fetch; the callback is the real handler.
101
+ const { pool } = require('../src/bongos/pool.js');
102
+ const { envPrefix } = require('../src/instance-config.js');
103
+ const coreDb = require('../src/bongos/db.js');
104
+ const buildAuthRouter = require('../src/bongos/routes/auth.js');
105
+
106
+ process.env[`${envPrefix()}_GITHUB_CLIENT_ID`] = 'test-client';
107
+ process.env[`${envPrefix()}_GITHUB_CLIENT_SECRET`] = 'test-secret';
108
+
109
+ const GH_TOKEN = 'gho_repo_scoped_token';
110
+ const ROW = {
111
+ id: 77, github_id: '4242', github_login: 'offboarded-ghost', display_name: 'Ghost', avatar_url: null,
112
+ total_credits: 12, created_at: new Date(0), rank: 'xenos', preferred_disciplines: null,
113
+ };
114
+ let builderRow = null;
115
+ const pick = (list) => Object.fromEntries(list.split(',').map((c) => c.trim()).map((c) => [c, builderRow[c]]));
116
+ pool.query = async (sql) => {
117
+ if (/INSERT INTO builders\b/.test(sql)) return { rows: [pick(sql.split('RETURNING')[1])] };
118
+ if (/FROM builders WHERE github_id/.test(sql)) return { rows: [pick(sql.match(/SELECT([\s\S]*?)FROM/)[1])] };
119
+ return { rows: [], rowCount: 0 };
120
+ };
121
+ globalThis.fetch = async (url) => {
122
+ if (String(url) === 'https://github.com/login/oauth/access_token') {
123
+ return { ok: true, json: async () => ({ access_token: GH_TOKEN, scope: 'read:user,repo' }) };
124
+ }
125
+ if (String(url) === 'https://api.github.com/user') {
126
+ return { ok: true, json: async () => ({ id: 4242, login: ROW.github_login, name: 'Ghost', avatar_url: null }) };
127
+ }
128
+ throw new Error(`unexpected fetch in test: ${url}`);
129
+ };
130
+ const tokenWrites = [];
131
+ coreDb.upsertGithubToken = async (args) => { tokenWrites.push(args); return { stored: true }; };
132
+
133
+ const callback = buildAuthRouter().stack
134
+ .find((l) => l.route?.path === '/auth/web/callback' && l.route.methods.get).route.stack[0].handle;
135
+ async function signInWithRepoOptIn() {
136
+ const req = {
137
+ query: { state: 'st8', code: 'c0de' }, secure: false,
138
+ headers: { cookie: 'gds_oauth_state=st8; gds_oauth_repos=1', host: 'localhost', 'user-agent': 'test' },
139
+ };
140
+ const res = {
141
+ redirectedTo: null, statusCode: 200, cookies: {},
142
+ cookie(name, value) { this.cookies[name] = value; return this; },
143
+ clearCookie() { return this; },
144
+ redirect(to) { this.redirectedTo = to; },
145
+ status(code) { this.statusCode = code; return this; },
146
+ type() { return this; },
147
+ send() { return this; },
148
+ };
149
+ await callback(req, res);
150
+ return res;
151
+ }
152
+
153
+ await t('offboarded builder: an opted-in web sign-in never reaches upsertGithubToken', async () => {
154
+ builderRow = { ...ROW, status: 'inactive' };
155
+ tokenWrites.length = 0;
156
+ const res = await signInWithRepoOptIn();
157
+ assert.equal(tokenWrites.length, 0, 'the token deactivateBuilder deleted must not be written back');
158
+ assert.ok(res.redirectedTo, 'sign-in itself is not refused: requireBuilder denies the session per request');
159
+ });
160
+
161
+ await t('offboarded builder: completeWebFlow never hands the repo token back', async () => {
162
+ builderRow = { ...ROW, status: 'inactive' };
163
+ const out = await auth.completeWebFlow({ code: 'c0de', redirectUri: 'http://localhost/cb', captureRepoToken: true });
164
+ assert.equal(out.githubToken, undefined);
165
+ assert.equal(out.githubScope, undefined);
166
+ });
167
+
168
+ await t('active builder: an opted-in web sign-in still captures the repo token (control)', async () => {
169
+ builderRow = { ...ROW, status: 'active' };
170
+ tokenWrites.length = 0;
171
+ await signInWithRepoOptIn();
172
+ assert.equal(tokenWrites.length, 1, 'the wizard repo-token capture keeps working');
173
+ assert.equal(tokenWrites[0].builderId, ROW.id);
174
+ assert.equal(tokenWrites[0].token, GH_TOKEN);
175
+ });
176
+
93
177
  console.log(`\n${pass} passed, ${fail} failed`);
94
178
  process.exit(fail === 0 ? 0 : 1);
@@ -76,7 +76,7 @@ test('scores mutuals and shared projects per D5\'s weights, caps mutuals at 10,
76
76
  assert.deepEqual(out.map((s) => s.handle), ['ada', 'grace'], '2003 is excluded and never appears; 2001 outranks 2002');
77
77
  assert.deepEqual(Object.keys(out[0]).sort(), ['avatar_url', 'display_name', 'handle', 'reason'],
78
78
  'an explicit whitelist — no score, no github_id');
79
- assert.equal(out[0].reason, '3 mutual connections', 'mutuals is the primary (and only) signal for ada');
79
+ assert.equal(out[0].reason, '3 connections in common', 'mutuals is the primary (and only) signal for ada — worded to match the profile page (task 1004253)');
80
80
  assert.equal(out[1].reason, 'You both build on Acme', 'shared projects outweighs the single mutual, and the project is named');
81
81
  });
82
82
 
@@ -554,7 +554,7 @@ test('suggestions: returns the ranked list with reasons, and costs the bounded r
554
554
  const res = await call('GET', '/api/bongos/connections/suggestions');
555
555
  assert.equal(res.status, 200);
556
556
  assert.deepEqual(res.json.suggestions.map((s) => s.handle), ['bob']);
557
- assert.equal(res.json.suggestions[0].reason, '2 mutual connections');
557
+ assert.equal(res.json.suggestions[0].reason, '2 connections in common');
558
558
  assert.equal(res.json.count, res.json.suggestions.length, 'the count is the array\'s own length');
559
559
  assert.equal(calls.length, 6, 'own account, then the three candidate reads, then identity + last-active');
560
560
  });
@@ -70,6 +70,8 @@ try {
70
70
  // handle assertion.
71
71
  await q(migrationSql('platform_identity_012_account_privacy.sql'));
72
72
  await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
73
+ // upsertAccount writes terms_accepted_source (024, task 1003360).
74
+ await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
73
75
  await cleanup();
74
76
 
75
77
  await q(
@@ -149,7 +149,8 @@ await test('the page carries the name-only panel, hidden until the server sends
149
149
  assert.match(PEOPLE_HTML, /id="people-named-h"[^>]*>[^<]+<span class="count-pill" id="people-named-count" hidden>/,
150
150
  'a heading with its own count');
151
151
  const lede = (PEOPLE_HTML.match(/<p class="scroll__lede" id="people-named-lede">([^<]+)<\/p>/) || [])[1] || '';
152
- assert.match(lede, /private/i, 'the lede says why these builders show no record');
152
+ assert.match(lede, /hidden/i, 'the lede says why these builders show no record, in the same word Settings uses (hide_stats)');
153
+ assert.doesNotMatch(lede, /private/i, '"private" is account_visibility\'s word, not hide_stats\'');
153
154
  assert.match(lede, /ranked/i, 'and that they are never ranked');
154
155
  assert.match(lede, /filter/i, 'and that a role filter never finds them');
155
156
  assert.match(PEOPLE_HTML, /id="people-named-body"/, 'and a mount for the ledger');
@@ -950,12 +950,12 @@ test('the own profile renders "builders you may know" with the server\'s own rea
950
950
  reset();
951
951
  const page = boot({
952
952
  transport: suggestTransport([
953
- { handle: 'grace', display_name: 'Grace H', avatar_url: null, reason: '3 mutual connections' },
953
+ { handle: 'grace', display_name: 'Grace H', avatar_url: null, reason: '3 connections in common' },
954
954
  ]),
955
955
  });
956
956
  await page.render(profileOf('octo', 7), 7);
957
957
  assert.match(page.html(), /Builders you may know/);
958
- assert.match(page.html(), /3 mutual connections/, 'the server picked the reason; the page states it verbatim');
958
+ assert.match(page.html(), /3 connections in common/, 'the server picked the reason; the page states it verbatim');
959
959
  assert.match(page.html(), /data-act="suggest-connect"/);
960
960
  assert.match(page.html(), /data-handle="grace"/);
961
961
  });
@@ -96,6 +96,9 @@ try {
96
96
  await q(migrationSql('platform_identity_012_recent_ships.sql'));
97
97
  await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
98
98
  await q(migrationSql('platform_identity_020_visibility_pull.sql'));
99
+ // upsertAccount writes terms_accepted_source (024, task 1003360). It runs
100
+ // before the fixtures, so its one-shot grandfather never sees LEGACY below.
101
+ await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
99
102
  await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
100
103
  await cleanup();
101
104
 
@@ -115,6 +115,18 @@ t('the acceptance the page promises is the one the code actually keeps', () => {
115
115
  'and that it is written once — the property the COALESCE below actually provides');
116
116
  });
117
117
 
118
+ t('the page says which acceptances were given on a builder’s behalf, and that the record marks them (task 1003360)', () => {
119
+ assert.match(termsHtml, /accepted it on your behalf/i,
120
+ 'the owner chose to grandfather AND to say so on the page — the page must tell a pre-existing builder what was recorded for them');
121
+ assert.match(termsHtml, /marks that acceptance as grandfathered/i,
122
+ 'and that the record keeps it distinguishable from one they gave');
123
+ assert.match(termsHtml, /your own acceptance replaces it the next time you sign in/i,
124
+ 'and what happens when they do sign in — the conflict branch below really does replace it');
125
+ assert.doesNotMatch(termsHtml, /written only by signing in/i,
126
+ 'the page still says acceptance is written ONLY by signing in, which is false for every grandfathered account');
127
+ assert.doesNotMatch(stripApexOnly(termsHtml), /grandfathered/i, 'a claim about the platform record survived into a child project page');
128
+ });
129
+
118
130
  t('the cross-project claim is apex-only — it never ships in a child project page', () => {
119
131
  const stripped = stripApexOnly(termsHtml);
120
132
  assert.doesNotMatch(stripped, /Your work across projects/,
@@ -139,20 +151,24 @@ t('the acceptance fragments refuse anything that is not a Postgres placeholder',
139
151
  }
140
152
  });
141
153
 
142
- t('the conflict fragment is COALESCE-shaped — the first acceptance is the one that stands', () => {
154
+ t('the conflict fragment keeps the first acceptance, and replaces only a grandfather', () => {
143
155
  const set = acceptanceConflictSet('$5', '$6');
144
- assert.match(set, /COALESCE\(platform_identity_accounts\.terms_accepted_at, now\(\)\)/,
156
+ const self = 'platform_identity_accounts.';
157
+ const replaces = `CASE WHEN $5::boolean AND ${self}terms_accepted_source = 'grandfathered'`;
158
+ assert.ok(set.includes(`terms_accepted_at = ${replaces} THEN now() WHEN $5::boolean THEN COALESCE(${self}terms_accepted_at, now()) ELSE ${self}terms_accepted_at END,`),
145
159
  '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');
160
+ assert.ok(set.includes(`terms_version = ${replaces} THEN $6::text WHEN $5::boolean THEN COALESCE(${self}terms_version, $6::text) ELSE ${self}terms_version END,`));
161
+ assert.ok(set.includes(`terms_accepted_source = CASE WHEN $5::boolean THEN 'signin' ELSE ${self}terms_accepted_source END,`),
162
+ 'a sign-in is a human accepting: the source must say so, or a grandfather mark could outlive a real acceptance');
149
163
  });
150
164
 
151
- t('the insert fragment writes NULL for a caller the hub did not witness', () => {
165
+ t('the insert fragment writes NULL for a caller the hub did not witness, and marks a witnessed one as a sign-in', () => {
152
166
  const values = acceptanceInsertValues('$5', '$6');
153
167
  assert.match(values, /CASE WHEN \$5::boolean THEN now\(\) ELSE NULL END/,
154
168
  'a check-in / device-poll account has no acceptance — fail-closed');
155
- assert.equal(acceptanceInsertColumns(), ', terms_accepted_at, terms_version');
169
+ assert.match(values, /CASE WHEN \$5::boolean THEN 'signin' ELSE NULL END/,
170
+ 'an acceptance written at sign-in must say so (task 1003360) — an unmarked one is indistinguishable from a grandfather');
171
+ assert.equal(acceptanceInsertColumns(), ', terms_accepted_at, terms_version, terms_accepted_source');
156
172
  });
157
173
 
158
174
  // ===========================================================================
@@ -183,10 +199,11 @@ function splitTopLevel(s) {
183
199
  // permissive `.*?` match: every shape this understands is spelled out, and an
184
200
  // unrecognised one THROWS. A SET list matched loosely is how a deleted audit
185
201
  // trail passes a test that looks like it is checking one.
186
- function evalAcceptance(expr, { col, activate, existing, params, now }) {
202
+ function evalAcceptance(expr, { col, activate, existing, oldRow = {}, params, now }) {
187
203
  const self = `platform_identity_accounts.${col}`;
188
204
  const src = (tok) => {
189
205
  if (/^now\(\)$/i.test(tok)) return now;
206
+ if (tok === "'signin'") return 'signin';
190
207
  const m = /^\$(\d+)::text$/.exec(tok);
191
208
  if (m) return params[Number(m[1]) - 1];
192
209
  throw new Error(`unrecognised source expression "${tok}" for ${col}`);
@@ -194,7 +211,18 @@ function evalAcceptance(expr, { col, activate, existing, params, now }) {
194
211
  const esc = self.replace(/[.]/g, '\\.');
195
212
  let m;
196
213
 
197
- // The shipped, idempotent shape: activate ? (existing ?? src) : existing
214
+ // The shipped shape (task 1003360): a grandfather is replaced, anything else is
215
+ // COALESCE'd. It reads the OLD row's source, as Postgres evaluates every SET
216
+ // right-hand side against the row before the update.
217
+ m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean AND platform_identity_accounts\\.terms_accepted_source = 'grandfathered' THEN (.+?) WHEN \\$(\\d+)::boolean THEN COALESCE\\(${esc}, (.+?)\\) ELSE ${esc} END$`, 'i').exec(expr);
218
+ if (m) {
219
+ if (!activate) return existing;
220
+ if (oldRow.terms_accepted_source === 'grandfathered') return src(m[2]);
221
+ return existing != null ? existing : src(m[4]);
222
+ }
223
+
224
+ // The ADR 0213 shape: activate ? (existing ?? src) : existing. Recognised so a
225
+ // regression to it is caught by behaviour (a grandfather that never gives way).
198
226
  m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean THEN COALESCE\\(${esc}, (.+?)\\) ELSE ${esc} END$`, 'i').exec(expr);
199
227
  if (m) return activate ? (existing != null ? existing : src(m[2])) : existing;
200
228
 
@@ -218,6 +246,9 @@ function evalAcceptance(expr, { col, activate, existing, params, now }) {
218
246
 
219
247
  const NOW = '2026-08-29T12:00:00.000Z';
220
248
  const LATER = '2026-08-30T09:30:00.000Z';
249
+ const GRANDFATHERED_AT = '2026-08-29T00:00:00.000Z';
250
+ // The three columns one acceptance writes (task 1003360 added the source).
251
+ const ACCEPTANCE_COLUMNS = ['terms_accepted_at', 'terms_version', 'terms_accepted_source'];
221
252
 
222
253
  function makePool() {
223
254
  const accounts = new Map(); // github_id -> row
@@ -242,7 +273,7 @@ function makePool() {
242
273
  const existing = accounts.get(id);
243
274
 
244
275
  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' });
276
+ if (!existing) accounts.set(id, { github_id: id, github_login: params[1], profile_state: 'provisional', terms_accepted_at: null, terms_version: null, terms_accepted_source: null, account_visibility: 'public', hide_stats: false, handle: 'h' });
246
277
  return { rows: [], rowCount: existing ? 0 : 1 };
247
278
  }
248
279
 
@@ -255,7 +286,7 @@ function makePool() {
255
286
  let row;
256
287
  if (!existing) {
257
288
  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']) {
289
+ for (const col of ACCEPTANCE_COLUMNS) {
259
290
  const i = cols.indexOf(col);
260
291
  assert.notEqual(i, -1, `the upsert stopped writing ${col} on insert`);
261
292
  row[col] = evalAcceptance(values[i], { col, activate, existing: null, params, now: clock });
@@ -271,9 +302,10 @@ function makePool() {
271
302
  const eq = a.indexOf('=');
272
303
  assigns.set(a.slice(0, eq).trim(), a.slice(eq + 1).trim());
273
304
  }
274
- for (const col of ['terms_accepted_at', 'terms_version']) {
305
+ const oldRow = { ...row };
306
+ for (const col of ACCEPTANCE_COLUMNS) {
275
307
  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 });
308
+ row[col] = evalAcceptance(assigns.get(col), { col, activate, existing: oldRow[col], oldRow, params, now: clock });
277
309
  }
278
310
  if (activate) row.profile_state = 'active';
279
311
  }
@@ -375,6 +407,8 @@ await ta('CONTROL: a hub-session authorize records the acceptance', async () =>
375
407
  assert.ok(row, 'no account was written at all — the rest of this section would pass vacuously');
376
408
  assert.equal(row.terms_accepted_at, NOW, 'the acceptance was not stamped by the hub sign-in');
377
409
  assert.equal(row.terms_version, TERMS_VERSION, 'the record must name WHICH text was accepted');
410
+ assert.equal(row.terms_accepted_source, 'signin',
411
+ 'and HOW it was given — a sign-in, queryably distinct from a grandfather (task 1003360)');
378
412
  assert.equal(row.profile_state, 'active');
379
413
  });
380
414
 
@@ -389,12 +423,13 @@ await ta('the acceptance cannot be forged from the query string or the body', as
389
423
  const res = await request(
390
424
  `/sso/authorize?client_id=proj-a&redirect_uri=${encodeURIComponent(REDIRECT)}`
391
425
  + `&terms_accepted_at=${encodeURIComponent(forged)}&terms_version=FORGED&activate=true`
392
- + '&profile_state=active',
426
+ + '&profile_state=active&terms_accepted_source=grandfathered',
393
427
  );
394
428
  assert.equal(res.status, 302);
395
429
  const row = pool.accounts.get('4242');
396
430
  assert.equal(row.terms_accepted_at, NOW, 'a caller-supplied timestamp reached the acceptance record');
397
431
  assert.equal(row.terms_version, TERMS_VERSION, 'a caller-supplied version reached the acceptance record');
432
+ assert.equal(row.terms_accepted_source, 'signin', 'a caller-supplied source reached the acceptance record');
398
433
 
399
434
  // And structurally: the forged values are nowhere in the statement's params,
400
435
  // so there is no path by which a later refactor could start reading them.
@@ -403,6 +438,7 @@ await ta('the acceptance cannot be forged from the query string or the body', as
403
438
  const flat = JSON.stringify(upserts[0].params);
404
439
  assert.doesNotMatch(flat, /FORGED/, 'a caller-controlled value was bound into the acceptance statement');
405
440
  assert.doesNotMatch(flat, /1999/, 'a caller-controlled timestamp was bound into the acceptance statement');
441
+ assert.doesNotMatch(flat, /grandfathered/, 'a caller-controlled source was bound into the acceptance statement');
406
442
  assert.equal(upserts[0].params.length, 6,
407
443
  'the statement grew a parameter — every one of them must come from the hub session or a constant');
408
444
  assert.equal(upserts[0].params[5], TERMS_VERSION, 'the version parameter must be the server constant');
@@ -448,6 +484,7 @@ await ta('signing in twice does not rewrite or duplicate the acceptance', async
448
484
  assert.equal(row.terms_accepted_at, NOW,
449
485
  `the second sign-in restamped the acceptance (${row.terms_accepted_at}) — the record no longer says when consent was actually given`);
450
486
  assert.equal(row.terms_version, TERMS_VERSION);
487
+ assert.equal(row.terms_accepted_source, 'signin');
451
488
  assert.equal(pool.accounts.size, 1, 'a second row was created — one account, one acceptance');
452
489
  });
453
490
 
@@ -478,6 +515,63 @@ await ta('a federated check-in creates an account with NO acceptance at all', as
478
515
  assert.equal(row.profile_state, 'provisional');
479
516
  });
480
517
 
518
+ // A grandfathered acceptance (platform_identity_024, task 1003360) was given on
519
+ // the builder's behalf, not by them. It keeps the account published, but it is
520
+ // not the first acceptance a HUMAN gave — so it must give way to one.
521
+ const grandfathered = () => ({
522
+ github_id: '4242', github_login: 'octo', profile_state: 'active', handle: 'h',
523
+ account_visibility: 'public', hide_stats: false,
524
+ terms_accepted_at: GRANDFATHERED_AT, terms_version: '2026-08-29', terms_accepted_source: 'grandfathered',
525
+ });
526
+
527
+ await ta('a grandfathered acceptance gives way to the builder’s own at their next sign-in', async () => {
528
+ SESSION = HUB_SESSION;
529
+ pool.accounts.clear();
530
+ pool.accounts.set('4242', grandfathered());
531
+ pool.setClock(LATER);
532
+ await authorize();
533
+ const row = pool.accounts.get('4242');
534
+ assert.equal(row.terms_accepted_source, 'signin',
535
+ 'the builder signed in and accepted, yet the record still says the acceptance was given on their behalf');
536
+ assert.equal(row.terms_accepted_at, LATER, 'the date must be when THEY accepted, not when the grandfather ran');
537
+ assert.equal(row.terms_version, TERMS_VERSION, 'and name the text served when they did');
538
+
539
+ // ...and from then on it is a sign-in like any other: written once.
540
+ pool.setClock('2026-09-01T00:00:00.000Z');
541
+ await authorize();
542
+ assert.equal(pool.accounts.get('4242').terms_accepted_at, LATER,
543
+ 'a second sign-in restamped an acceptance the builder gave themselves');
544
+ });
545
+
546
+ await ta('an acceptance with no source on record is a real sign-in: it keeps its date and is marked signin', async () => {
547
+ // The deploy window: migrate.sh commits 024 while the old server still runs, and
548
+ // a builder signing in then gets an acceptance the old code wrote without a
549
+ // source. Treating "not signin" as "replace me" would erase the date they gave.
550
+ SESSION = HUB_SESSION;
551
+ pool.accounts.clear();
552
+ pool.accounts.set('4242', { ...grandfathered(), terms_accepted_at: NOW, terms_version: TERMS_VERSION, terms_accepted_source: null });
553
+ pool.setClock(LATER);
554
+ await authorize();
555
+ const row = pool.accounts.get('4242');
556
+ assert.equal(row.terms_accepted_at, NOW, 'a sign-in restamped an acceptance a human gave, because its source was blank');
557
+ assert.equal(row.terms_accepted_source, 'signin', 'and the source should now say how it was given');
558
+ });
559
+
560
+ await ta('a write that is not the capture point leaves a grandfathered acceptance marked grandfathered', async () => {
561
+ pool.accounts.clear();
562
+ pool.accounts.set('4242', grandfathered());
563
+ pool.setClock(LATER);
564
+ const pi = require('../modules/platform-identity/platform-identity.js');
565
+ // The non-activating upsert (the /me/handle and device-poll paths) and the
566
+ // federated check-in. Neither witnessed an acceptance, so neither may turn a
567
+ // grandfather into a sign-in — that would be the fiction the mark prevents.
568
+ await pi.upsertAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
569
+ await pi.ensureAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
570
+ const row = pool.accounts.get('4242');
571
+ assert.equal(row.terms_accepted_source, 'grandfathered', 'a write that witnessed no acceptance relabelled a grandfather as a sign-in');
572
+ assert.equal(row.terms_accepted_at, GRANDFATHERED_AT);
573
+ });
574
+
481
575
  server.close();
482
576
 
483
577
  // ===========================================================================
@@ -503,12 +597,168 @@ t('the shared consent predicate requires the acceptance, in both forms', () => {
503
597
  assert.match(accountActiveSql('pa'), /pa\.terms_accepted_at IS NOT NULL/, 'and qualify it under an alias');
504
598
  });
505
599
 
600
+ t('a grandfathered account is consented and published — nothing goes dark (owner decision, blocker 1000123)', () => {
601
+ // The predicate asks whether an acceptance exists, never how it was given: the
602
+ // source is for the ledger, not a second visibility rule (ADR 0202 D4).
603
+ const row = { profile_state: 'active', terms_accepted_at: GRANDFATHERED_AT, terms_accepted_source: 'grandfathered', account_visibility: 'public' };
604
+ assert.equal(isAccountActive(row), true, 'a grandfathered account went dark — the outcome the owner chose against');
605
+ assert.equal(isAccountVisible(row), true);
606
+ assert.doesNotMatch(accountActiveSql(), /terms_accepted_source/,
607
+ 'the consent predicate started reading the source — a grandfather would stop publishing, or a second rule was born');
608
+ });
609
+
506
610
  t('the page and the code agree on which version is current', () => {
507
611
  assert.match(termsHtml, new RegExp(TERMS_VERSION.replace(/-/g, '\\-')),
508
612
  `the page must state the version the code stamps (${TERMS_VERSION}) — a record naming a text the reader `
509
613
  + 'cannot find is not evidence of anything');
510
614
  });
511
615
 
616
+ // ===========================================================================
617
+ // 5b. The grandfather (platform_identity_024) — one-shot, marked, and narrow
618
+ // ===========================================================================
619
+
620
+ // The owner chose to grandfather existing accounts (blocker 1000123) and to MARK
621
+ // them as grandfathered. Static checks on the migration's own text pin that it
622
+ // is shaped that way; tests/terms_grandfather_db.mjs executes it against a real
623
+ // Postgres in the integration lane.
624
+
625
+ console.log('\nthe grandfather (existing accounts, marked as such):');
626
+
627
+ const MIGRATION = 'platform_identity_024_terms_grandfathered.sql';
628
+ const migrationSrc = fs.readFileSync(path.join(repo, 'modules', 'platform-identity', 'migrations', MIGRATION), 'utf8');
629
+ const migrationSql = migrationSrc.replace(/--[^\n]*/g, '').replace(/\s+/g, ' ');
630
+
631
+ t('the migration adds a source column that can only say signin or grandfathered', () => {
632
+ assert.match(migrationSql, /ADD COLUMN terms_accepted_source text CHECK \(terms_accepted_source IN \('signin', 'grandfathered'\)\)/,
633
+ 'the source must be constrained — a free-text marker is one typo away from a third, meaningless kind of acceptance');
634
+ });
635
+
636
+ t('existing sign-in acceptances are marked as sign-ins BEFORE anything is grandfathered', () => {
637
+ const signin = migrationSql.indexOf("SET terms_accepted_source = 'signin' WHERE terms_accepted_at IS NOT NULL");
638
+ const grandfather = migrationSql.indexOf("terms_accepted_source = 'grandfathered'");
639
+ assert.notEqual(signin, -1, 'acceptances written by a real sign-in before this migration must be marked signin');
640
+ assert.notEqual(grandfather, -1, 'the grandfather write is missing');
641
+ assert.ok(signin < grandfather,
642
+ 'the signin backfill must run first — after the grandfather it would relabel every grandfathered row as a sign-in');
643
+ });
644
+
645
+ t('only accounts the hub already witnessed are grandfathered, and nobody is activated by it', () => {
646
+ // The version is the text in force when the grandfather ran. It is pinned, not
647
+ // read from TERMS_VERSION: a later revision must not rewrite what these rows were accepted under.
648
+ assert.match(migrationSql,
649
+ /SET terms_accepted_at = now\(\), terms_version = '2026-08-29', terms_accepted_source = 'grandfathered' WHERE profile_state = 'active' AND terms_accepted_at IS NULL;/,
650
+ 'the grandfather must stamp all three columns, and only on active accounts with no acceptance yet');
651
+ assert.equal((migrationSql.match(/profile_state/g) || []).length, 1,
652
+ 'the migration names profile_state outside its one WHERE — it must never activate an account (a provisional account was never public)');
653
+ });
654
+
655
+ t('the grandfather runs once — replaying the migration cannot grandfather an account created since', () => {
656
+ const guard = migrationSql.search(/IF NOT EXISTS \( SELECT 1 FROM information_schema\.columns WHERE table_schema = current_schema\(\) AND table_name = 'platform_identity_accounts' AND column_name = 'terms_accepted_source' \) THEN/);
657
+ const endIf = migrationSql.indexOf('END IF;');
658
+ assert.notEqual(guard, -1, 'the data writes must be guarded on the column not existing yet');
659
+ for (const write of ['ADD COLUMN terms_accepted_source', "terms_accepted_source = 'signin'", "terms_accepted_source = 'grandfathered'"]) {
660
+ const at = migrationSql.indexOf(write);
661
+ assert.ok(at > guard && at < endIf, `${write} sits outside the one-shot guard`);
662
+ }
663
+ });
664
+
665
+ // ===========================================================================
666
+ // 5c. The write-site guard — one writer, and no runtime grandfather
667
+ // ===========================================================================
668
+
669
+ // tests/effective_visibility_predicate.mjs deliberately does NOT match a write
670
+ // (it proves so), so it cannot stop a second write site. This does: only
671
+ // terms-acceptance.js may spell a write of the acceptance columns, and no server
672
+ // code at all may write the grandfather marker — the migration is its only author.
673
+
674
+ console.log('\nthe write-site guard (one writer, no runtime grandfather):');
675
+
676
+ // Drop // and /* */ comments, keeping string and template-literal contents.
677
+ function stripComments(src) {
678
+ let out = '';
679
+ let i = 0;
680
+ let quote = null;
681
+ while (i < src.length) {
682
+ const c = src[i];
683
+ const n = src[i + 1];
684
+ if (quote) {
685
+ out += c;
686
+ if (c === '\\') { out += n || ''; i += 2; continue; }
687
+ if (c === quote) quote = null;
688
+ i++;
689
+ continue;
690
+ }
691
+ if (c === '/' && n === '/') { while (i < src.length && src[i] !== '\n') i++; continue; }
692
+ if (c === '/' && n === '*') { const end = src.indexOf('*/', i + 2); i = end === -1 ? src.length : end + 2; continue; }
693
+ if (c === "'" || c === '"' || c === '`') quote = c;
694
+ out += c;
695
+ i++;
696
+ }
697
+ return out;
698
+ }
699
+
700
+ const ACCEPTANCE_WRITE = [
701
+ /\bterms_(?:accepted_at|version|accepted_source)\s*=(?!=)/g,
702
+ /INSERT\s+INTO\s+platform_identity_accounts\s*\([^)]*\bterms_(?:accepted_at|version|accepted_source)\b/gi,
703
+ ];
704
+ const GRANDFATHER_LITERAL = /(['"`])grandfathered\1/;
705
+ const acceptanceWritesIn = (code) => ACCEPTANCE_WRITE.flatMap((re) => [...code.matchAll(re)].map((m) => m[0]));
706
+
707
+ function serverSources() {
708
+ const out = [];
709
+ const walk = (dir) => {
710
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
711
+ const p = path.join(dir, e.name);
712
+ if (e.isDirectory()) { if (!['node_modules', 'public', 'tests', 'migrations'].includes(e.name)) walk(p); }
713
+ else if (e.name.endsWith('.js')) out.push(p);
714
+ }
715
+ };
716
+ walk(path.join(repo, 'src'));
717
+ walk(path.join(repo, 'modules'));
718
+ return out.map((abs) => ({ rel: path.relative(repo, abs).split(path.sep).join('/'), code: stripComments(fs.readFileSync(abs, 'utf8')) }));
719
+ }
720
+ const SOURCES = serverSources();
721
+ const WRITER = 'modules/platform-identity/terms-acceptance.js';
722
+
723
+ t('the guard catches a write in every form, and leaves reads and comparisons alone', () => {
724
+ assert.ok(acceptanceWritesIn('UPDATE platform_identity_accounts SET terms_accepted_at = now()').length, 'an UPDATE SET');
725
+ assert.ok(acceptanceWritesIn("terms_accepted_source='signin'").length, 'an unspaced assignment');
726
+ assert.ok(acceptanceWritesIn('INSERT INTO platform_identity_accounts (github_id, terms_version) VALUES ($1, $2)').length, 'an INSERT column list');
727
+ assert.ok(!acceptanceWritesIn('account.terms_accepted_at != null').length, 'a JS null comparison is not a write');
728
+ assert.ok(!acceptanceWritesIn('row.terms_accepted_source === x').length, 'nor a strict comparison');
729
+ assert.ok(!acceptanceWritesIn('SELECT profile_state, terms_accepted_at FROM platform_identity_accounts').length, 'nor a projection');
730
+ assert.ok(!acceptanceWritesIn(stripComments('// SET terms_accepted_at = now()\nconst x = 1;')).length, 'nor a comment');
731
+ assert.ok(stripComments("const u = 'https://x.example/a'; // c").includes('https://x.example/a'), 'a // inside a string is not a comment');
732
+ assert.ok(GRANDFATHER_LITERAL.test("a = 'grandfathered'"), 'the grandfather literal');
733
+ assert.ok(!GRANDFATHER_LITERAL.test(stripComments('// marked grandfathered\nconst y = 2;')), 'but not the word in prose');
734
+ });
735
+
736
+ t('terms-acceptance.js is the only server file that writes an acceptance', () => {
737
+ const writer = SOURCES.find((s) => s.rel === WRITER);
738
+ assert.ok(writer && acceptanceWritesIn(writer.code).length >= 3,
739
+ 'the one writer no longer spells its writes — this guard would now pass vacuously');
740
+ const others = SOURCES.filter((s) => s.rel !== WRITER && acceptanceWritesIn(s.code).length)
741
+ .map((s) => `${s.rel}: ${acceptanceWritesIn(s.code).join(' | ')}`);
742
+ assert.deepEqual(others, [],
743
+ 'a second acceptance write site — it bypasses the source marker and the capture-point proof above');
744
+ });
745
+
746
+ t('no server code can mark an acceptance grandfathered — only the migration ever did', () => {
747
+ assert.ok(SOURCES.length > 50, `the walk found only ${SOURCES.length} files — it is not looking where the code is`);
748
+ const hits = SOURCES.filter((s) => s.rel !== WRITER && GRANDFATHER_LITERAL.test(s.code)).map((s) => s.rel);
749
+ assert.deepEqual(hits, [],
750
+ 'server code names the grandfather marker — a runtime grandfather records a consent nobody gave');
751
+ // The writer may READ the marker (a grandfather is the one acceptance a sign-in
752
+ // replaces) and nothing else: every mention is a test of the OLD row's source.
753
+ // Writing it is also refused behaviourally — the interpreter above knows no
754
+ // 'grandfathered' source expression, so a SET or VALUES producing it throws.
755
+ const writer = SOURCES.find((s) => s.rel === WRITER).code;
756
+ const mentions = (writer.match(/'grandfathered'/g) || []).length;
757
+ const reads = (writer.match(/\$\{self\}\.terms_accepted_source = 'grandfathered'/g) || []).length;
758
+ assert.ok(mentions >= 1 && mentions === reads,
759
+ `terms-acceptance.js names the grandfather marker ${mentions} time(s), only ${reads} as a read of the old row`);
760
+ });
761
+
512
762
  // ===========================================================================
513
763
  // 6. The projection rule — a twin cannot judge a column it was not given
514
764
  // ===========================================================================