@bongos/core 1.21.57 → 1.21.59

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 (52) hide show
  1. package/.bongos-core.json +91 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +13 -2
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +2 -0
  8. package/docs/adr/0365-a-guild-engages-a-project-through-each-members-own-door.md +58 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +233 -6
  11. package/docs/api-reference.md +8 -5
  12. package/docs/copy-inventory.md +384 -376
  13. package/docs/copy-registry.json +514 -432
  14. package/docs/file-map.md +3 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +7 -1
  17. package/docs/page-readings.json +930 -919
  18. package/modules/hall-ui/public/goals-page.js +20 -1
  19. package/modules/hall-ui/public/task-lib.js +13 -0
  20. package/modules/hall-ui/public/work.js +22 -1
  21. package/modules/platform-identity/application-echo.js +11 -5
  22. package/modules/platform-identity/guild-engagements.js +255 -0
  23. package/modules/platform-identity/guilds.js +3 -1
  24. package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
  25. package/modules/platform-identity/routes/guilds.js +35 -1
  26. package/modules/platform-identity/routes/my-projects.js +27 -3
  27. package/modules/platform-identity/routes/projects.js +44 -0
  28. package/modules/platform-identity/routes/sso.js +21 -3
  29. package/modules/public-landing/public/assets/cosmos.css +7 -0
  30. package/modules/public-landing/public/guild.html +64 -2
  31. package/modules/public-landing/public/guild.probes.json +13 -3
  32. package/modules/public-landing/public/guild.states.json +10 -5
  33. package/modules/public-landing/public/projects.html +252 -16
  34. package/modules/public-landing/public/projects.probes.json +26 -1
  35. package/modules/public-landing/public/projects.states.json +4 -0
  36. package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
  37. package/modules/ui-design/kit/serve.js +54 -5
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/release-notes.json +12 -0
  41. package/scripts/gds/run-unit-tests.js +5 -0
  42. package/src/module-api.js +1 -1
  43. package/tests/applicant_profile_boundary.mjs +104 -1
  44. package/tests/application_echo.mjs +91 -5
  45. package/tests/focus_version_filter_default.mjs +47 -0
  46. package/tests/guild_engagement.mjs +881 -0
  47. package/tests/guild_engagement_db.mjs +240 -0
  48. package/tests/hall_mine_lens.mjs +1 -0
  49. package/tests/hub_engagement_cards.mjs +307 -0
  50. package/tests/hub_guild_page.mjs +94 -7
  51. package/tests/privacy_matrix.mjs +99 -9
  52. package/tests/project_invite_ui.mjs +187 -3
@@ -248,9 +248,18 @@ function activityScope(sql, params) {
248
248
  const CLIENT = { client_id: 'client-alpha', secret_hash: null, name: 'Alpha', redirect_uris: [], origin: 'https://alpha.example', owner_github_id: '1', status: 'active' };
249
249
  const OTHER_CLIENT = { client_id: 'client-beta', secret_hash: null, name: 'Beta', redirect_uris: [], origin: 'https://beta.example', owner_github_id: '2', status: 'active' };
250
250
  // (github_id, client_id) pairs the HUB witnessed, and whether they are inside
251
- // the read-time window. Only PUB and PRIVOUT applied to alpha.
251
+ // the read-time window. Only PUB and PRIVOUT applied to alpha. An echo may carry
252
+ // the guild the application was made with (task 1002303, ADR 0365 D5).
252
253
  let ECHOES = [];
253
254
 
255
+ // The guilds an echo can name, and who is (still) in them.
256
+ const GUILDS = [
257
+ { id: '81', slug: 'open-guild', name: 'Open Guild', visibility: 'public' },
258
+ { id: '82', slug: 'quiet-guild', name: 'Quiet Guild', visibility: 'unlisted' },
259
+ ];
260
+ let GUILD_MEMBERS = [];
261
+ let labelFails = false;
262
+
254
263
  let calls = [];
255
264
  const pool = {
256
265
  query: async (text, params = []) => {
@@ -262,6 +271,28 @@ const pool = {
262
271
  return { rows: hit ? [{ ...hit }] : [], rowCount: hit ? 1 : 0 };
263
272
  }
264
273
 
274
+ // THE GUILD LABEL (guild-engagements.js echoGuildLabel). Its membership
275
+ // requirement is a JOIN, so the join is read rather than assumed: drop it
276
+ // and an applicant who LEFT the guild genuinely keeps the label here.
277
+ if (/^SELECT g\.slug, g\.name, g\.visibility FROM platform_identity_application_echoes ae /.test(sql)) {
278
+ if (labelFails) throw new Error('the guild tables are on fire');
279
+ const m = /^SELECT g\.slug, g\.name, g\.visibility FROM platform_identity_application_echoes ae (.+?) WHERE (.+?) LIMIT 1$/.exec(sql);
280
+ if (!m) throw new Error(`fake pool: the label read moved — ${sql}`);
281
+ if (!/^JOIN platform_identity_guilds g ON g\.id = ae\.guild_id\b/.test(m[1])) throw new Error(`fake pool: the label join moved — ${m[1]}`);
282
+ const rest = m[1].replace(/^JOIN platform_identity_guilds g ON g\.id = ae\.guild_id/, '').trim();
283
+ const mustBeMember = rest === 'JOIN platform_identity_guild_members m ON m.guild_id = ae.guild_id AND m.github_id = ae.github_id';
284
+ if (rest && !mustBeMember) throw new Error(`fake pool: unmodelled label join — ${rest}`);
285
+ assert.equal(m[2], 'ae.github_id = $1::bigint AND ae.client_id = $2 AND ae.applied_at > now() - make_interval(days => $3::int)',
286
+ 'the label reads ONE echo — this account, this client, inside the 30-day window');
287
+ assert.equal(params[2], 30);
288
+ const rows = ECHOES
289
+ .filter((e) => String(e.github_id) === String(params[0]) && e.client_id === params[1] && e.in_window && e.guild_id)
290
+ .filter((e) => !mustBeMember || GUILD_MEMBERS.some((gm) => gm.guild_id === e.guild_id && gm.github_id === String(e.github_id)))
291
+ .map((e) => GUILDS.find((g) => g.id === e.guild_id))
292
+ .map((g) => ({ slug: g.slug, name: g.name, visibility: g.visibility, id: g.id }));
293
+ return { rows: rows.slice(0, 1), rowCount: Math.min(rows.length, 1) };
294
+ }
295
+
265
296
  // The GATE's own read. The window term is modelled explicitly rather than
266
297
  // waved through: an echo outside it must stop working, and a fake that
267
298
  // ignored `applied_at` would make that untestable.
@@ -584,6 +615,78 @@ test('a real applicant resolves, uncacheable, through the gate', async () => {
584
615
  'a cached applicant view is a snapshot, which is the one thing "live at review time" forbids');
585
616
  });
586
617
 
618
+ // ---------------------------------------------------------------------------
619
+ // The guild label (task 1002303, ADR 0365 D5): { profile, guild }
620
+ // ---------------------------------------------------------------------------
621
+
622
+ const labelReads = () => calls.filter((c) => /FROM platform_identity_application_echoes ae /.test(c.sql));
623
+
624
+ test('the guild label: { slug, name } beside the profile while the applicant is still in the guild', async () => {
625
+ ECHOES = [{ github_id: PUB, client_id: CLIENT.client_id, in_window: true, guild_id: '81' }];
626
+ GUILD_MEMBERS = [{ guild_id: '81', github_id: PUB }];
627
+ labelFails = false;
628
+ const r = await post('/api/bongos/sso/applicant-profile', { ...creds, github_id: PUB });
629
+ assert.equal(r.status, 200);
630
+ assert.deepEqual(Object.keys(r.json).sort(), ['guild', 'profile'], 'the label sits BESIDE the profile, never inside it');
631
+ assert.equal(r.json.profile.view, VIEW_PUBLIC);
632
+ assert.deepEqual(r.json.guild, { slug: 'open-guild', name: 'Open Guild' }, 'a slug and a name — no guild id, no visibility');
633
+ assert.equal(labelReads()[0].params[1], CLIENT.client_id, 'asked about the AUTHENTICATED client');
634
+ });
635
+
636
+ test('the guild label: an UNLISTED guild is named with slug null — it has no public page to open', async () => {
637
+ ECHOES = [{ github_id: PRIVOUT, client_id: CLIENT.client_id, in_window: true, guild_id: '82' }];
638
+ GUILD_MEMBERS = [{ guild_id: '82', github_id: PRIVOUT }];
639
+ labelFails = false;
640
+ const r = await post('/api/bongos/sso/applicant-profile', { ...creds, github_id: PRIVOUT });
641
+ assert.equal(r.json.profile.view, VIEW_SLIVER);
642
+ assert.deepEqual(r.json.guild, { slug: null, name: 'Quiet Guild' });
643
+ });
644
+
645
+ test('the guild label: null once the applicant LEFT the guild, and null for a solo application', async () => {
646
+ ECHOES = [{ github_id: PUB, client_id: CLIENT.client_id, in_window: true, guild_id: '81' }];
647
+ GUILD_MEMBERS = [];
648
+ labelFails = false;
649
+ const left = await post('/api/bongos/sso/applicant-profile', { ...creds, github_id: PUB });
650
+ assert.equal(left.json.profile.view, VIEW_PUBLIC);
651
+ assert.equal(left.json.guild, null, 'a guild they are no longer in does not vouch for them');
652
+
653
+ ECHOES = [{ github_id: PUB, client_id: CLIENT.client_id, in_window: true, guild_id: null }];
654
+ GUILD_MEMBERS = [{ guild_id: '81', github_id: PUB }];
655
+ const solo = await post('/api/bongos/sso/applicant-profile', { ...creds, github_id: PUB });
656
+ assert.equal(solo.json.guild, null);
657
+ });
658
+
659
+ test('the guild label: null whenever the profile is null — and then not even read', async () => {
660
+ ECHOES = [{ github_id: PROV, client_id: CLIENT.client_id, in_window: true, guild_id: '81' }];
661
+ GUILD_MEMBERS = [{ guild_id: '81', github_id: PROV }];
662
+ labelFails = false;
663
+ const r = await post('/api/bongos/sso/applicant-profile', { ...creds, github_id: PROV });
664
+ assert.equal(r.status, 200);
665
+ assert.equal(r.json.profile, null);
666
+ assert.equal(r.json.guild, null, 'an applicant who discloses nothing discloses no guild either');
667
+ assert.deepEqual(labelReads(), [], 'the label is read only beside a profile');
668
+ });
669
+
670
+ test('the guild label is BEST-EFFORT: a failed read answers guild null and never costs the profile', async () => {
671
+ ECHOES = [{ github_id: PUB, client_id: CLIENT.client_id, in_window: true, guild_id: '81' }];
672
+ GUILD_MEMBERS = [{ guild_id: '81', github_id: PUB }];
673
+ labelFails = true;
674
+ try {
675
+ const r = await post('/api/bongos/sso/applicant-profile', { ...creds, github_id: PUB });
676
+ assert.equal(r.status, 200);
677
+ assert.equal(r.json.profile.view, VIEW_PUBLIC);
678
+ assert.equal(r.json.guild, null);
679
+ } finally { labelFails = false; }
680
+ });
681
+
682
+ test('the guild label waits behind the echo gate: no application, no label read', async () => {
683
+ ECHOES = [{ github_id: PUB, client_id: OTHER_CLIENT.client_id, in_window: true, guild_id: '81' }];
684
+ GUILD_MEMBERS = [{ guild_id: '81', github_id: PUB }];
685
+ const r = await post('/api/bongos/sso/applicant-profile', { ...creds, github_id: PUB });
686
+ assert.equal(r.status, 404);
687
+ assert.deepEqual(labelReads(), []);
688
+ });
689
+
587
690
  test('bad credentials are refused before anything is read', async () => {
588
691
  ECHOES = [{ github_id: PUB, client_id: CLIENT.client_id, in_window: true }];
589
692
  const r = await post('/api/bongos/sso/applicant-profile', { client_id: CLIENT.client_id, client_secret: 'wrong', github_id: PUB });
@@ -75,7 +75,17 @@ test('the write is an upsert on the pair, refreshing the date (D1.4)', async ()
75
75
  const [{ sql, params }] = pool.calls;
76
76
  assert.match(sql, /ON CONFLICT \(github_id, client_id\) DO UPDATE SET applied_at = now\(\)/,
77
77
  're-applying is a success, so a second ask refreshes rather than erroring or piling up a row');
78
- assert.deepEqual(params, [String(ME), 'proj-a']);
78
+ assert.deepEqual(params, [String(ME), 'proj-a', null], 'a solo application binds NO guild');
79
+ });
80
+
81
+ test('the write carries the guild the application was made with, and every upsert rewrites it (task 1002303, ADR 0365 D1)', async () => {
82
+ const pool = recorder();
83
+ await recordApplication({ githubId: ME, clientId: 'proj-a', guildId: 71 }, { pool });
84
+ const [{ sql, params }] = pool.calls;
85
+ assert.match(sql, /INSERT INTO platform_identity_application_echoes \(github_id, client_id, applied_at, guild_id\) VALUES \(\$1::bigint, \$2, now\(\), \$3::bigint\)/);
86
+ assert.match(sql, /DO UPDATE SET applied_at = now\(\), guild_id = EXCLUDED\.guild_id$/,
87
+ 'the most recent application decides the label: a later solo one writes NULL over it');
88
+ assert.deepEqual(params, [String(ME), 'proj-a', '71']);
79
89
  });
80
90
 
81
91
  test('neither function carries a status, because the table holds none', async () => {
@@ -106,6 +116,10 @@ let echoFails = false;
106
116
  let statements = [];
107
117
  let account = null;
108
118
  let accountFails = false;
119
+ // (session, guild slug, client) triples that resolve to an engagement for the
120
+ // join's `guild` field (task 1002303).
121
+ const ENGAGED = [{ github_id: ME, slug: 'guild-a', client_id: 'proj-open', guild_id: '71' }];
122
+ let engagementFails = false;
109
123
 
110
124
  function seed() {
111
125
  const now = Date.now();
@@ -128,6 +142,7 @@ function seed() {
128
142
  { client_id: 'proj-invited', membership_kind: 'pending', joined_at: now - DAY, last_active: null, project_name: 'Invited', project_origin: 'https://invited.example', owner_github_id: null },
129
143
  ];
130
144
  echoFails = false;
145
+ engagementFails = false;
131
146
  statements = [];
132
147
  // An account that has cleared the consent floor: active, and the platform
133
148
  // terms accepted (account-visibility.js isAccountActive).
@@ -238,13 +253,28 @@ const pool = {
238
253
 
239
254
  if (/^INSERT INTO platform_identity_application_echoes/.test(sql)) {
240
255
  if (echoFails) throw new Error('echo table is on fire');
241
- const [githubId, clientId] = params;
256
+ const [githubId, clientId, guildId = null] = params;
257
+ // The conflict branch is READ from the statement, so dropping the guild_id
258
+ // rewrite from it genuinely leaves a stale label behind (task 1002303).
259
+ const rewritesGuild = /DO UPDATE SET .*\bguild_id = EXCLUDED\.guild_id\b/.test(sql);
242
260
  const existing = echoes.find((e) => String(e.github_id) === String(githubId) && e.client_id === clientId);
243
- if (existing) existing.applied_at = Date.now();
244
- else echoes.push({ github_id: Number(githubId), client_id: clientId, applied_at: Date.now() });
261
+ if (existing) {
262
+ existing.applied_at = Date.now();
263
+ if (rewritesGuild) existing.guild_id = guildId;
264
+ } else echoes.push({ github_id: Number(githubId), client_id: clientId, applied_at: Date.now(), guild_id: guildId });
245
265
  return { rows: [] };
246
266
  }
247
267
 
268
+ // The join's guild check (guild-engagements.js engagementFor) — the rule
269
+ // itself is interpreted in tests/guild_engagement.mjs; here only WHICH guild
270
+ // resolves for whom matters, keyed on the bound session, slug and client.
271
+ if (/^SELECT g\.id, g\.slug, g\.name FROM platform_identity_guild_members m /.test(sql)) {
272
+ if (engagementFails) throw new Error('engagement table is on fire');
273
+ assert.match(sql, /lower\(g\.slug\) = \$2 AND e\.client_id = \$3 LIMIT 1$/);
274
+ const hit = ENGAGED.find((x) => String(params[0]) === String(x.github_id) && params[1] === x.slug && params[2] === x.client_id);
275
+ return { rows: hit ? [{ id: hit.guild_id, slug: hit.slug, name: hit.slug }] : [] };
276
+ }
277
+
248
278
  if (/FROM platform_identity_application_echoes e/.test(sql)) {
249
279
  if (echoFails) throw new Error('echo table is on fire');
250
280
  m = /WHERE (.+?) ORDER BY/.exec(sql);
@@ -342,7 +372,7 @@ function call(method, pathname, body) {
342
372
  req.end(payload);
343
373
  });
344
374
  }
345
- const join = (note) => call('POST', '/api/bongos/my-projects/join', { origin: 'https://target.example', note });
375
+ const join = (note, guild) => call('POST', '/api/bongos/my-projects/join', { origin: 'https://target.example', note, ...(guild === undefined ? {} : { guild }) });
346
376
 
347
377
  test('the interpreter refuses a predicate it cannot read — parameterised or not', () => {
348
378
  assert.throws(() => predicateFor('github_id = $1::bigint AND something = $2', [1, 2]), /unread predicate on \$2/);
@@ -414,6 +444,62 @@ test('a failed echo write never turns a filed application into a reported failur
414
444
  assert.equal(r.json.status, 'requested');
415
445
  });
416
446
 
447
+ // ── applying WITH a guild (task 1002303, ADR 0365 D2) ─────────────────────────
448
+ // The guild is resolved for the SESSION before anything is relayed; one that
449
+ // no longer resolves relays nothing and echoes nothing, and one that does is the
450
+ // label the echo carries until the next application rewrites it.
451
+
452
+ test('a `guild` that no longer resolves is a 409 guild_engagement_gone, with nothing relayed and no echo', async () => {
453
+ for (const guild of ['other-guild', 'Not A Slug!', '']) {
454
+ seed();
455
+ echoes = echoes.filter((e) => e.client_id !== 'proj-open');
456
+ relay = { status: 201, body: { ok: true } };
457
+ const r = await join('please', guild);
458
+ assert.equal(r.status, 409, `guild ${JSON.stringify(guild)}`);
459
+ assert.equal(r.json.error.code, 'guild_engagement_gone');
460
+ assert.equal(relayed, null, 'the project never heard of it — the applicant chose to apply WITH the guild');
461
+ assert.equal(echo('proj-open'), undefined);
462
+ }
463
+ });
464
+
465
+ test('a guild that resolves is checked for the SESSION, relayed as an ordinary application, and lands on the echo', async () => {
466
+ seed();
467
+ echoes = echoes.filter((e) => e.client_id !== 'proj-open');
468
+ relay = { status: 201, body: { ok: true } };
469
+ const r = await join('please', 'Guild-A');
470
+ assert.equal(r.status, 200);
471
+ assert.equal(r.json.status, 'requested');
472
+ assert.ok(relayed, 'relayed');
473
+ assert.deepEqual(Object.keys(JSON.parse(relayed.init.body)).sort(), ['display_name', 'github_login', 'note'],
474
+ 'no new field reaches the project: the label travels on the applicant-profile read (ADR 0365 D5)');
475
+ const check = statements.find((x) => /^SELECT g\.id, g\.slug, g\.name FROM platform_identity_guild_members/.test(x.sql));
476
+ assert.deepEqual(check.params.slice(0, 3), [String(ME), 'guild-a', 'proj-open'], 'the session, the canonical slug, the resolved client');
477
+ assert.equal(echo('proj-open').guild_id, '71');
478
+ });
479
+
480
+ test('a later solo application clears the guild label — the most recent application decides', async () => {
481
+ seed();
482
+ relay = { status: 201, body: { ok: true } };
483
+ await join('with them', 'guild-a');
484
+ assert.equal(echo('proj-open').guild_id, '71');
485
+ await join('on my own');
486
+ assert.equal(echo('proj-open').guild_id, null);
487
+ });
488
+
489
+ test('a refused application leaves no echo even with a guild, and a broken guild check relays nothing', async () => {
490
+ seed();
491
+ echoes = echoes.filter((e) => e.client_id !== 'proj-open');
492
+ relay = { status: 403, body: { error: { code: 'invite_only' } } };
493
+ assert.equal((await join(undefined, 'guild-a')).json.status, 'invite_only');
494
+ assert.equal(echo('proj-open'), undefined);
495
+
496
+ seed();
497
+ engagementFails = true;
498
+ const r = await join(undefined, 'guild-a');
499
+ assert.equal(r.status, 500, 'a label the hub cannot verify is never attached by guesswork');
500
+ assert.equal(relayed, null);
501
+ });
502
+
417
503
  // ── the vouch the relay carries (R09, task 1002285; ADR 0201 D2) ─────────────
418
504
  // The ECHO above is what authorizes the project's later profile read
419
505
  // (applicationBacksProfileRead); the VOUCH is what tells the project whose
@@ -0,0 +1,47 @@
1
+ // tests/focus_version_filter_default.mjs — the Goals page and Tasks board open their
2
+ // Version filter on the project's focus version (task 1004552). The decision is the
3
+ // pure task-lib helper; the two pages are pinned to calling it after their options fill.
4
+ //
5
+ // Run: node --test tests/focus_version_filter_default.mjs
6
+
7
+ import assert from 'node:assert/strict';
8
+ import { test } from 'node:test';
9
+ import { readFileSync } from 'node:fs';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const { focusVersionDefault } = require('../modules/hall-ui/public/task-lib.js');
14
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
15
+ const opts = ['', 'BONGOS-V1', 'BONGOS-V2'];
16
+
17
+ test('a starred version is the default the filter opens on', () => {
18
+ assert.equal(focusVersionDefault({ focusId: 'BONGOS-V2', optionValues: opts, current: '' }), 'BONGOS-V2');
19
+ });
20
+
21
+ test('no focus leaves the filter on any — nothing is guessed', () => {
22
+ for (const focusId of [null, undefined, '']) {
23
+ assert.equal(focusVersionDefault({ focusId, optionValues: opts, current: '' }), null);
24
+ }
25
+ });
26
+
27
+ test('a URL filter wins over the focus default', () => {
28
+ assert.equal(focusVersionDefault({ focusId: 'BONGOS-V2', optionValues: opts, current: '', urlValue: 'BONGOS-V1' }), null);
29
+ });
30
+
31
+ test('a choice the builder already made wins over the focus default', () => {
32
+ assert.equal(focusVersionDefault({ focusId: 'BONGOS-V2', optionValues: opts, current: 'BONGOS-V1' }), null);
33
+ });
34
+
35
+ test('a focus the select has no option for is left alone, not selected as a blank', () => {
36
+ assert.equal(focusVersionDefault({ focusId: 'BONGOS-V9', optionValues: opts, current: '' }), null);
37
+ });
38
+
39
+ test('both pages read the focus and apply it after their version options fill', () => {
40
+ const work = src('modules/hall-ui/public/work.js');
41
+ assert.match(work, /\/versions\/focus/);
42
+ assert.match(work, /populateVersionFilter\(taskState\.versions\);\s*restoreUrlFilter\('f-version'\);\s*if \(applyFocusVersion\(focusResp\)\)/);
43
+ const goals = src('modules/hall-ui/public/goals-page.js');
44
+ assert.match(goals, /\/versions\/focus/);
45
+ assert.match(goals, /populateVersionFilter\(\);\s*applyFocusVersion\(focusResp\);/);
46
+ for (const page of [work, goals]) assert.match(page, /OTBTaskLib\.focusVersionDefault\(/);
47
+ });