@bongos/core 1.20.20 → 1.20.22

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 (49) hide show
  1. package/.bongos-core.json +80 -50
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -2
  4. package/clients/bongos-client/index.cjs +6 -2
  5. package/clients/bongos-client/index.d.ts +11 -4
  6. package/clients/bongos-client/index.mjs +6 -2
  7. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -0
  8. package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +70 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +179 -73
  11. package/docs/api-reference.md +8 -6
  12. package/docs/copy-inventory.md +196 -196
  13. package/docs/copy-registry.json +250 -250
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +2 -1
  16. package/docs/page-readings.json +310 -306
  17. package/modules/hall-ui/public/watch.js +23 -0
  18. package/modules/onboarding/routes/access-requests.js +36 -6
  19. package/modules/platform-identity/guild-map.js +105 -0
  20. package/modules/platform-identity/invite-notices.js +61 -0
  21. package/modules/platform-identity/module.json +1 -1
  22. package/modules/platform-identity/platform-identity.js +12 -18
  23. package/modules/platform-identity/routes/guilds-public.js +36 -0
  24. package/modules/platform-identity/routes/my-projects.js +6 -15
  25. package/modules/platform-identity/routes/projects.js +22 -15
  26. package/modules/platform-identity/routes/sso-invites.js +82 -0
  27. package/modules/platform-identity/tests/platform-identity.mjs +14 -11
  28. package/modules/public-landing/public/assets/cosmos.css +5 -0
  29. package/modules/public-landing/public/projects.html +35 -29
  30. package/modules/public-landing/public/projects.states.json +1 -0
  31. package/modules/ui-design/kit/serve.js +9 -0
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/release-notes.json +12 -0
  35. package/scripts/gds/run-unit-tests.js +4 -0
  36. package/src/bongos/auth-admission.js +40 -0
  37. package/src/bongos/auth.js +2 -1
  38. package/src/module-api.js +8 -1
  39. package/tests/access_requests_invite.mjs +39 -1
  40. package/tests/application_lifecycle.mjs +41 -1
  41. package/tests/catalog_only_client.mjs +5 -3
  42. package/tests/guild_map_db.mjs +157 -0
  43. package/tests/guilds_api.mjs +74 -0
  44. package/tests/hub_invite_notices.mjs +261 -0
  45. package/tests/module_api.mjs +1 -0
  46. package/tests/my_projects_surface.mjs +11 -1
  47. package/tests/project_invite_ownership.mjs +12 -7
  48. package/tests/project_invite_ui.mjs +42 -45
  49. package/tests/watch_applications_queue.mjs +33 -2
@@ -19,8 +19,10 @@
19
19
  // before that an invite could only ever 404
20
20
  // • an invite sends exactly { client_id, github_login } — the slug IS the
21
21
  // client_id — on the cookie session, and nothing else
22
- // • the 201 row is what the pending line is drawn from, and the github_id it
23
- // carries is never shown
22
+ // • the answer is a HAND-OFF (task 1002818, ADR 0353): the route writes nothing
23
+ // and answers `{ invited: false, invite_url }` — the project's hall invite link — and
24
+ // each name becomes a line whose link finishes the invite there — never a
25
+ // window opened for the owner, and never a link that is not http(s)
24
26
  // • every refusal the route can give is said in the route's own vocabulary,
25
27
  // and the 403 is said CAREFULLY: it is byte-identical whether or not the
26
28
  // project exists (ADR 0248), so the page may not read it as "it is there"
@@ -57,8 +59,14 @@ for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function ski
57
59
  // Narrow on purpose (the project_modules_ui.mjs rule): it supports exactly what
58
60
  // the region uses, so it stays honest about what it can prove.
59
61
 
60
- const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><\/li>/g;
61
- const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2] }));
62
+ const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><a class="btn small" href="([^"]*)" target="_blank" rel="noopener">Send the invite →<\/a><\/li>/g;
63
+ const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2], url: m[3] }));
64
+
65
+ // The route's answer to an admitted caller since task 1002818: where to send the invite.
66
+ const handoff = (login, origin = 'https://mercury.cloudbongos.test') => ({
67
+ ok: true, status: 200,
68
+ body: { invited: false, invite_url: origin + '/builders/watch?invite=' + encodeURIComponent(login) },
69
+ });
62
70
 
63
71
  // A suggestion row: whatever the row NAMES, plus the login its button carries.
64
72
  // Parsed off the button so the test reads the address the click will actually
@@ -113,7 +121,8 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
113
121
  const queued = answers.slice();
114
122
  const fetch = (url, init = {}) => {
115
123
  requests.push({ url, method: init.method || 'GET', body: init.body, credentials: init.credentials, headers: init.headers });
116
- const a = queued.shift() || { ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 4242, membership_kind: 'pending' } } };
124
+ const sent = (() => { try { return JSON.parse(init.body).github_login; } catch { return ''; } })();
125
+ const a = queued.shift() || handoff(sent);
117
126
  if (a.reject) return Promise.reject(new Error('offline'));
118
127
  return Promise.resolve({ ok: a.ok, status: a.status, _json: a.body, json: () => Promise.resolve(a.body) });
119
128
  };
@@ -127,7 +136,12 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
127
136
  apiError: (data, status) => ({
128
137
  code: (data && data.error && data.error.code) || ('http_' + status),
129
138
  message: (data && data.error && data.error.message) || '',
139
+ details: (data && data.error && data.error.details) || {},
130
140
  }),
141
+ safeHttpUrl: (raw) => {
142
+ try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; }
143
+ },
144
+ URL,
131
145
  signInAgain: () => 'Your session expired — <a href="/in">sign in again</a>.',
132
146
  };
133
147
  sandbox.globalThis = sandbox;
@@ -238,7 +252,7 @@ test('an invite carries exactly { client_id, github_login } — the slug IS the
238
252
  'exactly the two fields the route validates, and nothing else');
239
253
  });
240
254
 
241
- test('the pending line is drawn from the 201 ROW, and the github_id on it is never shown', async () => {
255
+ test('the answer is a hand-off line: the name, "not sent yet", and the hall link that sends it', async () => {
242
256
  const ui = boot();
243
257
  await ui.open('mercury');
244
258
 
@@ -246,22 +260,29 @@ test('the pending line is drawn from the 201 ROW, and the github_id on it is nev
246
260
  await ui.send();
247
261
 
248
262
  assert.deepEqual(ui.rows().map((r) => r.login), ['octo-builder']);
249
- assert.match(ui.rows()[0].said, /waiting for them to accept/i, "the row's own 'pending' is what the line says");
250
- assert.equal(ui.msg().className, 'projMsg ok');
251
- assert.doesNotMatch(ui.msg().textContent + ui.rows()[0].said + ui.rows()[0].login, /4242/,
252
- 'the row carries a github_id; a person who has accepted nothing yet is not published by it');
263
+ assert.match(ui.rows()[0].said, /not sent yet/i, 'the line may not claim an invite the hall has not sent');
264
+ assert.equal(ui.rows()[0].url, 'https://mercury.cloudbongos.test/builders/watch?invite=octo-builder',
265
+ 'the link is the route\'s own invite_url, verbatim');
266
+ assert.equal(ui.msg().className, 'projMsg ok', 'being handed to the hall is the answer, not a failure');
267
+ assert.match(ui.msg().textContent, /hall/i);
253
268
  });
254
269
 
255
- test('a membership_kind the page does not know is shown VERBATIM, never papered over as pending', async () => {
256
- // The state word is the row's, not this page's: if the route ever seeds a
257
- // different kind, an owner sees that rather than a comfortable fiction.
258
- const ui = boot({ answers: [{ ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 1, membership_kind: 'member' } } }] });
270
+ test('a hand-off link that is not http(s) is never drawn — it is said as a failure instead', async () => {
271
+ const ui = boot({ answers: [{ ok: true, status: 200, body: { invited: false, invite_url: 'javascript:alert(1)' } }] });
259
272
  await ui.open('mercury');
260
273
 
261
274
  await ui.type('octo-builder');
262
275
  await ui.send();
263
276
 
264
- assert.equal(ui.rows()[0].said, 'member');
277
+ assert.equal(ui.rows().length, 0, 'no line carries a link that is not a web address');
278
+ assert.doesNotMatch(ui.sandbox.$('wizInviteList').innerHTML, /javascript:/);
279
+ assert.equal(ui.msg().className, 'projMsg err');
280
+ assert.match(ui.msg().textContent, /couldn’t be sent/);
281
+ });
282
+
283
+ test('the region never opens a window for the owner — the link is theirs to click', () => {
284
+ assert.doesNotMatch(REGION, /window\.open|location\.(?:href|assign|replace)\s*[=(]/,
285
+ 'an open fired from a network answer is blocked, and a surprise navigation loses the wizard');
265
286
  });
266
287
 
267
288
  test('a name is trimmed, and an empty one is a question rather than a request', async () => {
@@ -299,12 +320,7 @@ test('Enter in the field sends the invite too', async () => {
299
320
  });
300
321
 
301
322
  test('two invites in a row both stand, newest last', async () => {
302
- const ui = boot({
303
- answers: [
304
- { ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 1, membership_kind: 'pending' } } },
305
- { ok: true, status: 201, body: { invited: true, row: { id: 8, client_id: 'mercury', github_id: 2, membership_kind: 'pending' } } },
306
- ],
307
- });
323
+ const ui = boot({ answers: [handoff('octo-builder'), handoff('second-builder')] });
308
324
  await ui.open('mercury');
309
325
 
310
326
  await ui.type('octo-builder');
@@ -317,29 +333,11 @@ test('two invites in a row both stand, newest last', async () => {
317
333
 
318
334
  // ── every refusal the route can give ─────────────────────────────────────────
319
335
 
320
- test('an invitee with no platform account is named honestly, and the step survives it', async () => {
321
- const ui = boot({ answers: [refused(404, 'account_not_found', 'That builder has no platform account yet — they must sign in somewhere once before they can be invited.')] });
322
- await ui.open('mercury');
323
-
324
- await ui.type('nobody-here');
325
- await ui.send();
326
-
327
- assert.equal(ui.msg().className, 'projMsg err');
328
- assert.match(ui.msg().textContent, /hasn’t signed in here yet/i);
329
- assert.equal(ui.box().hidden, false, 'the step stays — a failed invite is re-tryable');
330
- assert.equal(ui.rows().length, 0, 'and nothing is drawn as pending that is not');
331
- });
332
-
333
- test('someone already on the project is said plainly, not as a failure', async () => {
334
- const ui = boot({ answers: [{ ok: true, status: 200, body: { invited: false, alreadyMember: true } }] });
335
- await ui.open('mercury');
336
-
337
- await ui.type('octo-builder');
338
- await ui.send();
339
-
340
- assert.equal(ui.msg().className, 'projMsg ok');
341
- assert.match(ui.msg().textContent, /already on this project/i);
342
- assert.equal(ui.rows().length, 0, 'an existing member is not a pending invite');
336
+ // Task 1002818 retired two answers with the hub row they described: `account_not_found`
337
+ // (the hall invites any GitHub login, account or not) and 200 `alreadyMember` (the hall's
338
+ // own invite route answers that, where the membership actually lives).
339
+ test('the refusals the retired write used to give are gone from the page too', () => {
340
+ assert.doesNotMatch(REGION, /account_not_found|alreadyMember/);
343
341
  });
344
342
 
345
343
  test('a project the map has not caught up with says exactly that, and stays re-tryable', async () => {
@@ -407,7 +405,6 @@ test('an unknown refusal falls through to the server’s own words rather than a
407
405
 
408
406
  test('inviteRefusal is a pure function over the route’s own vocabulary', () => {
409
407
  const f = boot().sandbox.inviteRefusal;
410
- assert.match(f('account_not_found', 404, ''), /signed in here yet/i);
411
408
  assert.match(f('unknown_project', 404, ''), /map yet/i);
412
409
  assert.match(f('permission_forbidden', 403, ''), /can’t invite/i);
413
410
  assert.equal(f('whatever', 500, 'the server said this'), 'the server said this');
@@ -94,6 +94,7 @@ async function boot(opts = {}) {
94
94
  rank = 'archon',
95
95
  queueStatus = null, // non-200 for the queue read (403 seals it)
96
96
  patchResult = null, // a scripted answer for PATCH /access-requests/:id
97
+ search = '', // the page's query string (?invite=<login>, task 1002818)
97
98
  } = opts;
98
99
 
99
100
  const requests = [];
@@ -136,7 +137,7 @@ async function boot(opts = {}) {
136
137
  // the ones shipped rather than ones this test invented.
137
138
  parseInto(documentObj.body, PAGE);
138
139
 
139
- const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search: '', hash: '', assign() {} };
140
+ const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search, hash: '', assign() {} };
140
141
 
141
142
  const kit = {
142
143
  makeTabs: () => ({}),
@@ -187,7 +188,7 @@ async function boot(opts = {}) {
187
188
  console: { log() {}, warn() {}, error() {} },
188
189
  setTimeout, clearTimeout, Promise, Date, Math, JSON, Error, isNaN,
189
190
  Number, String, Object, Array, Boolean, Set, Map, RegExp,
190
- encodeURIComponent, decodeURIComponent, parseInt, parseFloat,
191
+ encodeURIComponent, decodeURIComponent, parseInt, parseFloat, URLSearchParams,
191
192
  };
192
193
  sandbox.globalThis = sandbox;
193
194
  vm.createContext(sandbox);
@@ -491,4 +492,34 @@ await test('the profile changes what the row SAYS, never what it offers (ADR 001
491
492
  'a rich record is not a reason to admit and an empty one is not a reason to refuse — the decisions must be identical');
492
493
  });
493
494
 
495
+ // ── ?invite=<login>: the platform hub hands an owner here (task 1002818, ADR 0353) ──
496
+ // The hub's invite widget cannot admit anyone, so it links an owner to THIS box with the
497
+ // name filled in. Filling is all a link may do: the send stays the owner's own click.
498
+
499
+ await test('?invite= fills the invite box — and sends nothing on its own', async () => {
500
+ const h = await boot({ search: '?invite=octo-builder' });
501
+ const box = h.documentObj.getElementById('gate-invite-login');
502
+ assert.ok(box, 'the invite box rendered');
503
+ assert.equal(box.value, 'octo-builder');
504
+ assert.equal(h.requests.filter((r) => r.method === 'POST').length, 0,
505
+ 'a link anyone can craft must never be the act that admits');
506
+ });
507
+
508
+ await test('?invite= is spent on first use — a re-render does not put the name back', async () => {
509
+ const h = await boot({ search: '?invite=octo-builder' });
510
+ const box = h.documentObj.getElementById('gate-invite-login');
511
+ box.value = '';
512
+ await h.switchView('invited'); // the gate re-renders, invite box and all
513
+ assert.equal(h.documentObj.getElementById('gate-invite-login').value, '');
514
+ });
515
+
516
+ await test('?invite= that is not a GitHub username fills nothing', async () => {
517
+ for (const bad of ['?invite=%3Cscript%3E', '?invite=a%20b', '?invite=-lead', '?invite=' + 'x'.repeat(40)]) {
518
+ const h = await boot({ search: bad });
519
+ assert.equal(h.documentObj.getElementById('gate-invite-login').value || '', '', bad);
520
+ }
521
+ const at = await boot({ search: '?invite=%40octo' });
522
+ assert.equal(at.documentObj.getElementById('gate-invite-login').value, 'octo', 'a pasted @ is dropped, as the form itself does');
523
+ });
524
+
494
525
  summary();