@bongos/core 1.19.1026 → 1.19.1028

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 (39) hide show
  1. package/.bongos-core.json +77 -37
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +38 -0
  4. package/clients/bongos-client/index.cjs +38 -0
  5. package/clients/bongos-client/index.d.ts +60 -0
  6. package/clients/bongos-client/index.mjs +38 -0
  7. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +4 -2
  8. package/docs/api/openapi.json +1345 -182
  9. package/docs/api-reference.md +29 -2
  10. package/docs/copy-inventory.md +209 -201
  11. package/docs/copy-registry.json +329 -257
  12. package/docs/module-api-changelog.md +4 -0
  13. package/modules/hall-ui/public/builders.js +7 -4
  14. package/modules/platform-identity/connections.js +1 -0
  15. package/modules/platform-identity/guilds.js +705 -0
  16. package/modules/platform-identity/migrations/platform_identity_025_guilds.sql +100 -0
  17. package/modules/platform-identity/module.json +1 -1
  18. package/modules/platform-identity/read-rate-limit.js +3 -2
  19. package/modules/platform-identity/routes/guilds-public.js +57 -0
  20. package/modules/platform-identity/routes/guilds.js +393 -0
  21. package/modules/platform-identity/terms-acceptance.js +14 -5
  22. package/modules/public-landing/public/assets/cosmos.css +36 -0
  23. package/modules/public-landing/public/projects.html +229 -26
  24. package/modules/public-landing/public/terms.html +3 -3
  25. package/modules/ui-design/kit/fixtures/my-projects__invites.json +1 -0
  26. package/modules/ui-design/kit/serve.js +12 -1
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/run-unit-tests.js +5 -0
  30. package/src/module-api.js +1 -1
  31. package/tests/card_apply_ui.mjs +8 -1
  32. package/tests/curve_controls_delegation.mjs +87 -0
  33. package/tests/guilds_api.mjs +521 -0
  34. package/tests/guilds_db.mjs +343 -0
  35. package/tests/my_projects_surface.mjs +26 -14
  36. package/tests/own_scope_guard.mjs +5 -0
  37. package/tests/public_landing_projects.mjs +40 -0
  38. package/tests/terms_acceptance.mjs +74 -28
  39. package/tests/terms_grandfather_db.mjs +2 -2
@@ -1,10 +1,12 @@
1
1
  // tests/terms_acceptance.mjs
2
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).
3
+ // THE PLATFORM T&C ACCEPTANCE RECORD — "a sign-in the hub itself witnessed is the
4
+ // only thing that can say a human accepted the platform terms, it says so once,
5
+ // and nothing publishes without it" (ADR 0213, task 1002913; the artifact ADR
6
+ // 0141 §4 deferred cross-project-aggregation consent to and ADR 0171 D3 reserved
7
+ // a place for). That sign-in is GET /sso/authorize — a builder using their
8
+ // platform account to sign in to a PROJECT. Signing in to the hub itself stamps
9
+ // nothing yet (blocker 1000124); task 1003361 adds it as a second capture point.
8
10
  //
9
11
  // WHY THIS EXISTS. An acceptance record is only worth the evidence behind it. If
10
12
  // a caller can assert one — a body field, a query param, a header — then the
@@ -111,24 +113,44 @@ t('the terms page states what crosses between projects', () => {
111
113
  t('the acceptance the page promises is the one the code actually keeps', () => {
112
114
  assert.match(termsHtml, /stores the date you accepted these terms/i,
113
115
  '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,
116
+ assert.match(termsHtml, /signing in again does not change the date you accepted/i,
115
117
  'and that it is written once — the property the COALESCE below actually provides');
116
118
  });
117
119
 
120
+ // The capture point is GET /sso/authorize — reached when a builder uses their
121
+ // platform account to sign in to a PROJECT. Signing in to the hub itself stamps
122
+ // nothing (blocker 1000124, task 1003353), so the page must not say "here".
123
+ // Task 1003361 adds the hub's own sign-in as a second capture point; when it
124
+ // lands, this wording and §3 below change together.
125
+ t('the page names the sign-in the code actually watches — a project sign-in, not "here" (blocker 1000124)', () => {
126
+ const section = termsHtml.slice(termsHtml.indexOf('Your work across projects'), termsHtml.indexOf('Fair use'));
127
+ assert.doesNotMatch(section, /sign(?:ed|ing)? in here/i,
128
+ 'the page promises that signing in to this site records acceptance — the code never stamps that sign-in');
129
+ assert.match(section, /Once you have signed in to a project with your platform account and accepted these terms/,
130
+ 'what makes a profile public must name the moment the code captures');
131
+ assert.match(section, /never signed in to a project that way stays provisional/,
132
+ 'and the provisional case must be the same moment, negated');
133
+ assert.match(section, /written when you sign in to a project with your platform account, and only once/,
134
+ 'the acceptance paragraph must name the same capture point');
135
+ });
136
+
118
137
  t('the page says which acceptances were given on a builder’s behalf, and that the record marks them (task 1003360)', () => {
119
138
  assert.match(termsHtml, /accepted it on your behalf/i,
120
139
  '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
140
  assert.match(termsHtml, /marks that acceptance as grandfathered/i,
122
141
  '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,
142
+ assert.match(termsHtml, /your own acceptance replaces it the next time you sign in to a project/i,
124
143
  'and what happens when they do sign in — the conflict branch below really does replace it');
125
144
  assert.doesNotMatch(termsHtml, /written only by signing in/i,
126
145
  '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');
146
+ assert.doesNotMatch(stripApexOnly(termsHtml, false), /grandfathered/i, 'a claim about the platform record survived into a child project page');
128
147
  });
129
148
 
130
149
  t('the cross-project claim is apex-only — it never ships in a child project page', () => {
131
- const stripped = stripApexOnly(termsHtml);
150
+ // The hub flag is passed explicitly: its default reads config/modules.json, so
151
+ // an implicit call passes or fails with whatever this checkout has enabled.
152
+ assert.equal(stripApexOnly(termsHtml, true), termsHtml, 'the hub keeps every apex-only passage');
153
+ const stripped = stripApexOnly(termsHtml, false);
132
154
  assert.doesNotMatch(stripped, /Your work across projects/,
133
155
  'a claim about THE PLATFORM survived into a child project page (task 1002748)');
134
156
  // ...and the stripper did not simply eat the page.
@@ -165,7 +187,9 @@ t('the conflict fragment keeps the first acceptance, and replaces only a grandfa
165
187
  t('the insert fragment writes NULL for a caller the hub did not witness, and marks a witnessed one as a sign-in', () => {
166
188
  const values = acceptanceInsertValues('$5', '$6');
167
189
  assert.match(values, /CASE WHEN \$5::boolean THEN now\(\) ELSE NULL END/,
168
- 'a check-in / device-poll account has no acceptance — fail-closed');
190
+ 'an account no sign-in witnessed has no acceptance — fail-closed');
191
+ assert.match(values, /CASE WHEN \$5::boolean THEN \$6::text ELSE NULL END/,
192
+ 'and no version either: the pair is written together or not at all');
169
193
  assert.match(values, /CASE WHEN \$5::boolean THEN 'signin' ELSE NULL END/,
170
194
  'an acceptance written at sign-in must say so (task 1003360) — an unmarked one is indistinguishable from a grandfather');
171
195
  assert.equal(acceptanceInsertColumns(), ', terms_accepted_at, terms_version, terms_accepted_source');
@@ -199,8 +223,12 @@ function splitTopLevel(s) {
199
223
  // permissive `.*?` match: every shape this understands is spelled out, and an
200
224
  // unrecognised one THROWS. A SET list matched loosely is how a deleted audit
201
225
  // trail passes a test that looks like it is checking one.
202
- function evalAcceptance(expr, { col, activate, existing, oldRow = {}, params, now }) {
226
+ function evalAcceptance(expr, { col, existing, oldRow = {}, params, now }) {
203
227
  const self = `platform_identity_accounts.${col}`;
228
+ // A boolean placeholder resolves by its OWN number, exactly as `$N::text` does
229
+ // below — so a fragment gated on the wrong parameter reads as never activating,
230
+ // rather than scoring as correct because the interpreter assumed which one it was.
231
+ const on = (n) => params[Number(n) - 1] === true;
204
232
  const src = (tok) => {
205
233
  if (/^now\(\)$/i.test(tok)) return now;
206
234
  if (tok === "'signin'") return 'signin';
@@ -216,24 +244,24 @@ function evalAcceptance(expr, { col, activate, existing, oldRow = {}, params, no
216
244
  // right-hand side against the row before the update.
217
245
  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
246
  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]);
247
+ if (on(m[1]) && oldRow.terms_accepted_source === 'grandfathered') return src(m[2]);
248
+ if (on(m[3])) return existing != null ? existing : src(m[4]);
249
+ return existing;
222
250
  }
223
251
 
224
252
  // The ADR 0213 shape: activate ? (existing ?? src) : existing. Recognised so a
225
253
  // regression to it is caught by behaviour (a grandfather that never gives way).
226
254
  m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean THEN COALESCE\\(${esc}, (.+?)\\) ELSE ${esc} END$`, 'i').exec(expr);
227
- if (m) return activate ? (existing != null ? existing : src(m[2])) : existing;
255
+ if (m) return on(m[1]) ? (existing != null ? existing : src(m[2])) : existing;
228
256
 
229
257
  // The overwriting shape — a real possibility, and the one the idempotence
230
258
  // assertion below must be able to tell apart from the shipped one.
231
259
  m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean THEN (.+?) ELSE ${esc} END$`, 'i').exec(expr);
232
- if (m) return activate ? src(m[2]) : existing;
260
+ if (m) return on(m[1]) ? src(m[2]) : existing;
233
261
 
234
262
  // The INSERT-branch shape: activate ? src : NULL
235
263
  m = /^CASE WHEN \$(\d+)::boolean THEN (.+?) ELSE NULL END$/i.exec(expr);
236
- if (m) return activate ? src(m[2]) : null;
264
+ if (m) return on(m[1]) ? src(m[2]) : null;
237
265
 
238
266
  // Unconditional — a caller-independent write. Recognised so the "non-activating
239
267
  // paths never stamp" assertion can catch it rather than erroring out.
@@ -272,16 +300,28 @@ function makePool() {
272
300
  const id = String(params[0]);
273
301
  const existing = accounts.get(id);
274
302
 
275
- if (/ON CONFLICT \(github_id\) DO NOTHING/i.test(sql)) {
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' });
277
- return { rows: [], rowCount: existing ? 0 : 1 };
278
- }
279
-
280
303
  const valuesRaw = /VALUES \((.+?)\) ON CONFLICT/i.exec(sql);
281
304
  assert.ok(valuesRaw, `the upsert changed shape — update the interpreter, do not bypass it: ${sql.slice(0, 200)}`);
282
305
  const values = splitTopLevel(valuesRaw[1]);
283
306
  assert.equal(values.length, cols.length, 'the INSERT column list and VALUES list must line up');
284
307
 
308
+ // ensureAccount's insert-only write. Built from the statement's OWN column
309
+ // list, not a hardcoded row: were an acceptance column added to it, the
310
+ // row below would really carry it and the assertions would really fail.
311
+ if (/ON CONFLICT \(github_id\) DO NOTHING/i.test(sql)) {
312
+ if (existing) return { rows: [], rowCount: 0 };
313
+ const row = { github_id: id, github_login: params[1], account_visibility: 'public', hide_stats: false, handle: 'h' };
314
+ for (const col of ACCEPTANCE_COLUMNS) {
315
+ const i = cols.indexOf(col);
316
+ row[col] = i === -1 ? null : evalAcceptance(values[i], { col, existing: null, params, now: clock });
317
+ }
318
+ const ps = /^'(\w+)'$/.exec(values[cols.indexOf('profile_state')] || '');
319
+ assert.ok(ps, 'the insert-only write stopped stating its profile_state literally — update the interpreter');
320
+ row.profile_state = ps[1];
321
+ accounts.set(id, row);
322
+ return { rows: [], rowCount: 1 };
323
+ }
324
+
285
325
  const activate = params[4] === true;
286
326
  let row;
287
327
  if (!existing) {
@@ -289,7 +329,7 @@ function makePool() {
289
329
  for (const col of ACCEPTANCE_COLUMNS) {
290
330
  const i = cols.indexOf(col);
291
331
  assert.notEqual(i, -1, `the upsert stopped writing ${col} on insert`);
292
- row[col] = evalAcceptance(values[i], { col, activate, existing: null, params, now: clock });
332
+ row[col] = evalAcceptance(values[i], { col, existing: null, params, now: clock });
293
333
  }
294
334
  row.profile_state = activate ? 'active' : 'provisional';
295
335
  accounts.set(id, row);
@@ -305,7 +345,7 @@ function makePool() {
305
345
  const oldRow = { ...row };
306
346
  for (const col of ACCEPTANCE_COLUMNS) {
307
347
  assert.ok(assigns.has(col), `the conflict branch stopped writing ${col} — an account that signs in again would never record its acceptance`);
308
- row[col] = evalAcceptance(assigns.get(col), { col, activate, existing: oldRow[col], oldRow, params, now: clock });
348
+ row[col] = evalAcceptance(assigns.get(col), { col, existing: oldRow[col], oldRow, params, now: clock });
309
349
  }
310
350
  if (activate) row.profile_state = 'active';
311
351
  }
@@ -392,7 +432,7 @@ const authorize = (qs = '') => request(
392
432
  );
393
433
 
394
434
  // ===========================================================================
395
- // 3. The capture point — the hub's own sign-in, and nothing else
435
+ // 3. The capture point — the project sign-in leg (/sso/authorize), and nothing else
396
436
  // ===========================================================================
397
437
 
398
438
  console.log('\nthe capture point (only the hub can say a human accepted):');
@@ -405,7 +445,7 @@ await ta('CONTROL: a hub-session authorize records the acceptance', async () =>
405
445
  assert.equal(res.status, 302, 'the authorize leg should hand back a code');
406
446
  const row = pool.accounts.get('4242');
407
447
  assert.ok(row, 'no account was written at all — the rest of this section would pass vacuously');
408
- assert.equal(row.terms_accepted_at, NOW, 'the acceptance was not stamped by the hub sign-in');
448
+ assert.equal(row.terms_accepted_at, NOW, 'the acceptance was not stamped by the platform-account sign-in to a project');
409
449
  assert.equal(row.terms_version, TERMS_VERSION, 'the record must name WHICH text was accepted');
410
450
  assert.equal(row.terms_accepted_source, 'signin',
411
451
  'and HOW it was given — a sign-in, queryably distinct from a grandfather (task 1003360)');
@@ -494,13 +534,19 @@ await ta('a NON-activating write never touches an existing acceptance', async ()
494
534
  pool.setClock(NOW);
495
535
  await authorize();
496
536
 
497
- // ensureAccount is the federated path (check-in / device-poll): it must not
498
- // create, restamp, or clear an acceptance.
537
+ // Neither non-activating writer may create, restamp, or clear an acceptance:
538
+ // ensureAccount (the federated membership check-in, insert-only) and the
539
+ // non-activating upsertAccount (POST /sso/device/poll and PATCH /me/handle),
540
+ // whose conflict branch really runs here — its "leave it alone" ELSE half.
499
541
  const pi = require('../modules/platform-identity/platform-identity.js');
500
542
  pool.setClock(LATER);
501
543
  await pi.ensureAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
502
544
  assert.equal(pool.accounts.get('4242').terms_accepted_at, NOW,
503
545
  'a federated check-in changed the acceptance record');
546
+ await pi.upsertAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
547
+ const row = pool.accounts.get('4242');
548
+ assert.deepEqual([row.terms_accepted_at, row.terms_version, row.terms_accepted_source], [NOW, TERMS_VERSION, 'signin'],
549
+ 'a non-activating upsert (device poll, handle change) changed the acceptance record');
504
550
  });
505
551
 
506
552
  await ta('a federated check-in creates an account with NO acceptance at all', async () => {
@@ -4,7 +4,7 @@
4
4
  // Proves migration platform_identity_024 against real SQL: an account the hub
5
5
  // had already witnessed is published immediately after the migration, without
6
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.
7
+ // the grandfather runs once; and the owner's next project sign-in replaces it.
8
8
  // tests/terms_acceptance.mjs pins the same rules DB-free (the statement shapes
9
9
  // and the executed conflict branch); only a transaction engine can run the
10
10
  // migration's DO block and its UPDATEs.
@@ -106,7 +106,7 @@ try {
106
106
  await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
107
107
  ok((await row(LATE)).terms_accepted_at === null, 'replaying 024 grandfathers nobody new — the grandfather was an event, not a rule');
108
108
 
109
- // The owner's next hub sign-in replaces the grandfather with their own acceptance.
109
+ // The owner's next project sign-in replaces the grandfather with their own acceptance.
110
110
  await pi.upsertAccount({ githubId: LEGACY, githubLogin: 'legacy-b', activate: true }, { pool: client });
111
111
  const after = await row(LEGACY);
112
112
  ok(after.terms_accepted_source === 'signin', 'the next sign-in replaces the grandfather with a signin');