@bongos/core 1.19.1024 → 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 (32) hide show
  1. package/.bongos-core.json +50 -35
  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 +17 -15
  11. package/docs/copy-registry.json +41 -23
  12. package/docs/module-api-changelog.md +3 -1
  13. package/migrations/core_255_access_requests_invite_kind.sql +52 -0
  14. package/modules/government/catalog.js +1 -1
  15. package/modules/onboarding/routes/access-requests.js +62 -11
  16. package/modules/platform-identity/connection-suggestions.js +1 -1
  17. package/modules/platform-identity/migrations/platform_identity_024_terms_grandfathered.sql +68 -0
  18. package/modules/platform-identity/terms-acceptance.js +34 -14
  19. package/modules/public-landing/public/terms.html +2 -1
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/scripts/gds/run-unit-tests.js +5 -0
  23. package/src/module-api.js +1 -1
  24. package/tests/access_requests_invite.mjs +81 -4
  25. package/tests/application_lifecycle.mjs +174 -6
  26. package/tests/connection_suggestions.mjs +1 -1
  27. package/tests/connections_api.mjs +1 -1
  28. package/tests/handle_machinery.mjs +2 -0
  29. package/tests/profile_connections_ui.mjs +2 -2
  30. package/tests/profile_rollup_consent.mjs +3 -0
  31. package/tests/terms_acceptance.mjs +264 -14
  32. package/tests/terms_grandfather_db.mjs +144 -0
@@ -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
  // ===========================================================================
@@ -0,0 +1,144 @@
1
+ // tests/terms_grandfather_db.mjs — task 1003360 (owner decision on blocker
2
+ // 1000123; ADR 0217 D3, amending ADR 0213 D4). Real DB.
3
+ //
4
+ // Proves migration platform_identity_024 against real SQL: an account the hub
5
+ // had already witnessed is published immediately after the migration, without
6
+ // signing in again; its acceptance is queryably distinct from a real sign-in;
7
+ // the grandfather runs once; and the owner's next hub sign-in replaces it.
8
+ // tests/terms_acceptance.mjs pins the same rules DB-free (the statement shapes
9
+ // and the executed conflict branch); only a transaction engine can run the
10
+ // migration's DO block and its UPDATEs.
11
+ //
12
+ // It builds its own tables in a throwaway schema, so it cannot see or disturb a
13
+ // shared database's rows, and the migration's one-shot guard (keyed on the
14
+ // column existing) starts from a genuinely fresh table every run.
15
+ //
16
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
17
+ // INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
18
+ // never runs it.
19
+ //
20
+ // Run: DATABASE_URL=... node tests/terms_grandfather_db.mjs
21
+
22
+ import { createRequire } from 'node:module';
23
+ import { readFileSync } from 'node:fs';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { dirname, join } from 'node:path';
26
+ import { strict as assert } from 'node:assert';
27
+ const require = createRequire(import.meta.url);
28
+ const { Pool } = require('pg');
29
+ const pi = require('../modules/platform-identity/platform-identity.js');
30
+ const { accountActiveSql } = require('../modules/platform-identity/account-visibility.js');
31
+ const { TERMS_VERSION } = require('../modules/platform-identity/terms-acceptance.js');
32
+
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
35
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
36
+
37
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
38
+ let client;
39
+ try {
40
+ client = await pool.connect();
41
+ } catch (e) {
42
+ console.log(`terms_grandfather_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
43
+ await pool.end().catch(() => {});
44
+ process.exit(0);
45
+ }
46
+
47
+ const SCHEMA = `t1003360_${process.pid}`;
48
+ const LEGACY = 90336001, SIGNED = 90336002, PROV = 90336003, LATE = 90336004;
49
+ const q = (sql, params) => client.query(sql, params);
50
+ const row = async (id) => (await q(
51
+ 'SELECT profile_state, terms_accepted_at, terms_version, terms_accepted_source FROM platform_identity_accounts WHERE github_id = $1', [id],
52
+ )).rows[0];
53
+
54
+ let pass = 0;
55
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
56
+
57
+ try {
58
+ await q(`CREATE SCHEMA ${SCHEMA}`);
59
+ await q(`SET search_path TO ${SCHEMA}`);
60
+ // The columns upsertAccount writes and reads: 001 the table, 007 consent, 008
61
+ // the handle, 009 display_name_source, 019 the acceptance pair.
62
+ for (const f of ['001_tables', '007_profile_consent', '008_handle', '009_profile_fields', '019_terms_acceptance']) {
63
+ await q(migrationSql(`platform_identity_${f}.sql`));
64
+ }
65
+
66
+ // The world 024 lands on: a legacy account the hub witnessed before the terms
67
+ // existed, one that accepted by a real sign-in since, and one never witnessed.
68
+ await q(
69
+ `INSERT INTO platform_identity_accounts (github_id, github_login, handle, profile_state, terms_accepted_at, terms_version)
70
+ VALUES ($1, 'legacy-b', 'legacy-b', 'active', NULL, NULL),
71
+ ($2, 'signed-b', 'signed-b', 'active', '2026-09-01T10:00:00Z', '2026-08-29'),
72
+ ($3, 'prov-b', 'prov-b', 'provisional', NULL, NULL)`,
73
+ [LEGACY, SIGNED, PROV],
74
+ );
75
+ const published = async () => (await q(
76
+ `SELECT github_id::text FROM platform_identity_accounts WHERE ${accountActiveSql()} ORDER BY github_id`,
77
+ )).rows.map((r) => Number(r.github_id));
78
+ ok((await published()).join() === String(SIGNED), 'before 024: the legacy account is dark (ADR 0213 D4, the state the owner chose against)');
79
+
80
+ await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
81
+
82
+ // Done-when 1: visible immediately, without signing in again.
83
+ ok((await published()).join() === [LEGACY, SIGNED].join(), 'after 024: the legacy account is published again, and nobody else newly is');
84
+
85
+ // Done-when 2: queryably distinct from a real sign-in.
86
+ const legacy = await row(LEGACY);
87
+ ok(legacy.terms_accepted_source === 'grandfathered', 'the legacy acceptance is marked grandfathered');
88
+ ok(legacy.terms_version === '2026-08-29' && legacy.terms_accepted_at !== null, 'and names the text and moment it was recorded under');
89
+ const signed = await row(SIGNED);
90
+ ok(signed.terms_accepted_source === 'signin', 'an acceptance a real sign-in wrote is marked signin');
91
+ ok(signed.terms_accepted_at.toISOString() === '2026-09-01T10:00:00.000Z', 'and its date is left exactly as the builder gave it');
92
+ const prov = await row(PROV);
93
+ ok(prov.profile_state === 'provisional' && prov.terms_accepted_at === null && prov.terms_accepted_source === null,
94
+ 'a never-witnessed account is neither activated nor grandfathered — it was never public');
95
+ const counts = (await q(
96
+ `SELECT terms_accepted_source AS s, count(*)::int AS n FROM platform_identity_accounts
97
+ WHERE terms_accepted_source IS NOT NULL GROUP BY 1 ORDER BY 1`,
98
+ )).rows.map((r) => `${r.s}:${r.n}`).join();
99
+ ok(counts === 'grandfathered:1,signin:1', `an audit can count the two kinds apart (${counts})`);
100
+
101
+ // One-shot: an account that turns up legacy-shaped later is not grandfathered by a replay.
102
+ await q(
103
+ `INSERT INTO platform_identity_accounts (github_id, github_login, handle, profile_state) VALUES ($1, 'late-b', 'late-b', 'active')`,
104
+ [LATE],
105
+ );
106
+ await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
107
+ ok((await row(LATE)).terms_accepted_at === null, 'replaying 024 grandfathers nobody new — the grandfather was an event, not a rule');
108
+
109
+ // The owner's next hub sign-in replaces the grandfather with their own acceptance.
110
+ await pi.upsertAccount({ githubId: LEGACY, githubLogin: 'legacy-b', activate: true }, { pool: client });
111
+ const after = await row(LEGACY);
112
+ ok(after.terms_accepted_source === 'signin', 'the next sign-in replaces the grandfather with a signin');
113
+ ok(after.terms_accepted_at > legacy.terms_accepted_at && after.terms_version === TERMS_VERSION,
114
+ 'dated when they signed in, under the text served then');
115
+ await pi.upsertAccount({ githubId: LEGACY, githubLogin: 'legacy-b', activate: true }, { pool: client });
116
+ ok((await row(LEGACY)).terms_accepted_at.getTime() === after.terms_accepted_at.getTime(), 'and a second sign-in does not restamp it');
117
+
118
+ await pi.upsertAccount({ githubId: SIGNED, githubLogin: 'signed-b', activate: true }, { pool: client });
119
+ ok((await row(SIGNED)).terms_accepted_at.toISOString() === '2026-09-01T10:00:00.000Z', 'an existing signin is never restamped either');
120
+
121
+ await pi.upsertAccount({ githubId: PROV, githubLogin: 'prov-b' }, { pool: client });
122
+ ok((await row(PROV)).terms_accepted_source === null, 'a non-activating upsert records no acceptance');
123
+
124
+ // The deploy window: 024 has committed but the old server wrote this sign-in,
125
+ // with no source. It is a real acceptance, so the next sign-in keeps its date.
126
+ await q(`UPDATE platform_identity_accounts SET terms_accepted_at = '2026-09-02T08:00:00Z', terms_version = '2026-08-29' WHERE github_id = $1`, [LATE]);
127
+ await pi.upsertAccount({ githubId: LATE, githubLogin: 'late-b', activate: true }, { pool: client });
128
+ const late = await row(LATE);
129
+ ok(late.terms_accepted_at.toISOString() === '2026-09-02T08:00:00.000Z' && late.terms_accepted_source === 'signin',
130
+ 'an acceptance written without a source keeps its date and is marked signin');
131
+
132
+ // Last, and unparameterised: nothing after an erroring statement depends on how the session recovers.
133
+ await assert.rejects(
134
+ q(`UPDATE platform_identity_accounts SET terms_accepted_source = 'implied' WHERE github_id = ${LEGACY}`),
135
+ /check constraint/i,
136
+ );
137
+ ok(true, 'the source column refuses a third kind of acceptance');
138
+
139
+ console.log(`\nterms_grandfather_db.mjs: ${pass} passed`);
140
+ } finally {
141
+ await q(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
142
+ client.release();
143
+ await pool.end().catch(() => {});
144
+ }