@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.
- package/.bongos-core.json +77 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +38 -0
- package/clients/bongos-client/index.cjs +38 -0
- package/clients/bongos-client/index.d.ts +60 -0
- package/clients/bongos-client/index.mjs +38 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +4 -2
- package/docs/api/openapi.json +1345 -182
- package/docs/api-reference.md +29 -2
- package/docs/copy-inventory.md +209 -201
- package/docs/copy-registry.json +329 -257
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/builders.js +7 -4
- package/modules/platform-identity/connections.js +1 -0
- package/modules/platform-identity/guilds.js +705 -0
- package/modules/platform-identity/migrations/platform_identity_025_guilds.sql +100 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/read-rate-limit.js +3 -2
- package/modules/platform-identity/routes/guilds-public.js +57 -0
- package/modules/platform-identity/routes/guilds.js +393 -0
- package/modules/platform-identity/terms-acceptance.js +14 -5
- package/modules/public-landing/public/assets/cosmos.css +36 -0
- package/modules/public-landing/public/projects.html +229 -26
- package/modules/public-landing/public/terms.html +3 -3
- package/modules/ui-design/kit/fixtures/my-projects__invites.json +1 -0
- package/modules/ui-design/kit/serve.js +12 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/card_apply_ui.mjs +8 -1
- package/tests/curve_controls_delegation.mjs +87 -0
- package/tests/guilds_api.mjs +521 -0
- package/tests/guilds_db.mjs +343 -0
- package/tests/my_projects_surface.mjs +26 -14
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/public_landing_projects.mjs +40 -0
- package/tests/terms_acceptance.mjs +74 -28
- 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
|
|
4
|
-
// that can say a human accepted the platform terms, it says so once,
|
|
5
|
-
// publishes without it" (ADR 0213, task 1002913; the artifact ADR
|
|
6
|
-
// deferred cross-project-aggregation consent to and ADR 0171 D3 reserved
|
|
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
|
|
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
|
-
|
|
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
|
-
'
|
|
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,
|
|
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 (
|
|
220
|
-
if (
|
|
221
|
-
return existing
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
//
|
|
498
|
-
//
|
|
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
|
|
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
|
|
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');
|