@bongos/core 1.21.56 → 1.21.58

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 (51) hide show
  1. package/.bongos-core.json +106 -46
  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 +361 -315
  13. package/docs/copy-registry.json +868 -419
  14. package/docs/file-map.md +4 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +38 -4
  17. package/docs/page-readings.json +856 -771
  18. package/modules/platform-identity/application-echo.js +11 -5
  19. package/modules/platform-identity/guild-engagements.js +255 -0
  20. package/modules/platform-identity/guilds.js +3 -1
  21. package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
  22. package/modules/platform-identity/routes/guilds.js +35 -1
  23. package/modules/platform-identity/routes/my-projects.js +27 -3
  24. package/modules/platform-identity/routes/projects.js +44 -0
  25. package/modules/platform-identity/routes/sso.js +21 -3
  26. package/modules/public-landing/public/assets/cosmos.css +7 -0
  27. package/modules/public-landing/public/guild.html +633 -0
  28. package/modules/public-landing/public/guild.probes.json +63 -0
  29. package/modules/public-landing/public/guild.states.json +43 -0
  30. package/modules/public-landing/public/projects.html +252 -16
  31. package/modules/public-landing/public/projects.probes.json +26 -1
  32. package/modules/public-landing/public/projects.states.json +4 -0
  33. package/modules/ui-design/kit/fixtures/guilds.json +73 -0
  34. package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
  35. package/modules/ui-design/kit/serve.js +99 -5
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +12 -0
  39. package/scripts/gds/run-unit-tests.js +5 -0
  40. package/src/module-api.js +1 -1
  41. package/src/platform-server.js +13 -0
  42. package/tests/applicant_profile_boundary.mjs +104 -1
  43. package/tests/application_echo.mjs +91 -5
  44. package/tests/guild_engagement.mjs +881 -0
  45. package/tests/guild_engagement_db.mjs +240 -0
  46. package/tests/hub_engagement_cards.mjs +307 -0
  47. package/tests/hub_guild_page.mjs +624 -0
  48. package/tests/privacy_matrix.mjs +216 -14
  49. package/tests/project_invite_ui.mjs +187 -3
  50. package/tests/render_check.mjs +2 -2
  51. package/tests/ui_design_kit.mjs +3 -3
@@ -153,14 +153,26 @@ function identitySentinels(acct) {
153
153
  // directory GET /scouting Recruiter — 'full' | 'name-only' | 'absent'
154
154
  // sliver GET /scouting/:handle Recruiter — 'full' (D3) | 'name-only' (D3, hidden) | 404
155
155
  // self GET /me/cross-project Self — 'own' (own numbers, any flags) | 'n/a' (no session exists)
156
+ // guild GET /guilds/:slug Anon · Builder · Recruiter · Self — the guild page's PUBLIC
157
+ // roster (task 1002301; ADR 0336 D4), viewer-independent: 'listed' | 'absent'.
158
+ // Every fixture account is a member with shown_publicly ON, so the account
159
+ // predicates alone decide; hide_stats never hides membership (D4 §2)
160
+ // members GET /guilds/:slug/members a fellow Member — the own-scope roster: 'listed' |
161
+ // 'absent'. Joining is consent to be seen BY THE GUILD (D4 §1), so a private
162
+ // member is listed here and nowhere public; a provisional account is never
163
+ // invite POST /projects/invite-guild a project's Inviter (task 1002303; ADR 0365 D3) — one
164
+ // hall link per member of a PUBLIC guild's public roster: 'listed' | 'absent'.
165
+ // It reads accounts across projects, so it may hand the inviter exactly the
166
+ // people the guild page already shows them — shown_publicly AND existence;
167
+ // hide_stats never hides membership, and private or provisional is never listed
156
168
  const MATRIX = {
157
- PROV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'n/a' },
158
- PUB: { profile: 'full', search: 'listed', board: 'listed', directory: 'full', sliver: 'full', self: 'own' },
159
- SHY: { profile: 'identity', search: 'name-only', board: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own' },
160
- PRIV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'own' },
161
- PRIVD: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'full', self: 'own' },
162
- PRIVDH: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'name-only', self: 'own' },
163
- OPTOUT: { profile: 'full', search: 'listed', board: 'listed', directory: 'absent', sliver: 404, self: 'own' },
169
+ PROV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'n/a', guild: 'absent', members: 'absent', invite: 'absent' },
170
+ PUB: { profile: 'full', search: 'listed', board: 'listed', directory: 'full', sliver: 'full', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
171
+ SHY: { profile: 'identity', search: 'name-only', board: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
172
+ PRIV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
173
+ PRIVD: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'full', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
174
+ PRIVDH: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'name-only', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
175
+ OPTOUT: { profile: 'full', search: 'listed', board: 'listed', directory: 'absent', sliver: 404, self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
164
176
  };
165
177
 
166
178
  // ===========================================================================
@@ -243,10 +255,89 @@ function activityScope(sql, params) {
243
255
  return String(params[Number(m[1]) - 1]);
244
256
  }
245
257
 
258
+ // THE GUILDS (task 1002301): one public and one unlisted guild, each with every
259
+ // fixture account as a member and shown_publicly ON — the most permissive guild
260
+ // flag, so the account predicates alone decide each cell. PUB owns both.
261
+ const GUILDS = [
262
+ { id: '880001', slug: 'mx-guild', name: 'Matrix Guild', description: 'mx-guild-desc', visibility: 'public', created_at: 'T0' },
263
+ { id: '880002', slug: 'mx-unlisted', name: 'Matrix Unlisted', description: 'mx-unlisted-desc', visibility: 'unlisted', created_at: 'T0' },
264
+ ];
265
+ const GUILD_MEMBERS = GUILDS.flatMap((g) => ACCOUNTS.map((a) => ({
266
+ guild_id: g.id, github_id: a.github_id, membership_kind: a.key === 'PUB' ? 'owner' : 'member', shown_publicly: true, counted_in_totals: true,
267
+ })));
268
+ /** The members-of-a-guild statements: the guild and shown terms consumed here, the rest read as an account filter. */
269
+ function guildRosterFilter(sql, params) {
270
+ let where = lift(sql, /JOIN platform_identity_accounts pa ON pa\.github_id = m\.github_id WHERE (.+?) ORDER BY/, 'guild roster');
271
+ if (!/\bm\.guild_id = \$1\b/.test(where)) throw new Error(`fake pool: a guild roster with no guild scope — ${where}`);
272
+ where = where.replace(/\bm\.guild_id = \$1\b/, '');
273
+ const shownOnly = /\bm\.shown_publicly\b/.test(where);
274
+ where = where.replace(/\bm\.shown_publicly\b/, '');
275
+ const byAccount = accountFilter(where, []);
276
+ return (m) => m.guild_id === String(params[0]) && (!shownOnly || m.shown_publicly) && byAccount(ACCOUNTS.find((a) => a.github_id === m.github_id));
277
+ }
278
+
279
+ // THE PROJECT a guild is invited to (task 1002303): one active client, whose
280
+ // origin the hall links point at.
281
+ const MX_CLIENT = { client_id: 'mx-proj', secret_hash: '', name: 'MX Project', redirect_uris: [], origin: 'https://mx-proj.example', owner_github_id: null, status: 'active' };
282
+
246
283
  const pool = {
247
284
  query: async (text, params = []) => {
248
285
  const sql = String(text).replace(/\s+/g, ' ').trim();
249
286
 
287
+ // POST /projects/invite-guild's reads (task 1002303). The client the gate resolves.
288
+ if (/FROM platform_identity_sso_clients WHERE client_id = \$1$/.test(sql)) {
289
+ const rows = params[0] === MX_CLIENT.client_id ? [{ ...MX_CLIENT }] : [];
290
+ return { rows, rowCount: rows.length };
291
+ }
292
+ // ... the guild, by slug — its WHERE is read, not assumed: PUBLIC only.
293
+ if (/^SELECT id, slug, name FROM platform_identity_guilds WHERE /.test(sql)) {
294
+ if (!/WHERE lower\(slug\) = \$1 AND visibility = 'public'$/.test(sql)) throw new Error(`fake pool: the invite-guild lookup moved — ${sql}`);
295
+ const rows = GUILDS.filter((g) => g.slug === String(params[0]) && g.visibility === 'public').map(({ id, slug, name }) => ({ id, slug, name }));
296
+ return { rows, rowCount: rows.length };
297
+ }
298
+ // ... the `invited` engagement it records (no account in it, so nothing to model here).
299
+ if (/^INSERT INTO platform_identity_guild_engagements /.test(sql)) return { rows: [], rowCount: 1 };
300
+ // ... and the PUBLIC roster the hall links go to, read through the same roster
301
+ // interpreter as the guild page — its projection pinned, since the login it
302
+ // reads becomes part of each link.
303
+ if (/^SELECT pa\.handle, pa\.display_name, pa\.github_login FROM platform_identity_guild_members m JOIN platform_identity_accounts pa/.test(sql)) {
304
+ const keep = guildRosterFilter(sql, params);
305
+ const rows = GUILD_MEMBERS.filter(keep).map((m) => {
306
+ const a = ACCOUNTS.find((x) => x.github_id === m.github_id);
307
+ // github_id and the bio ride along so a projection that spreads the row leaks them
308
+ return { handle: a.handle, display_name: a.display_name, github_login: a.github_login, github_id: a.github_id, bio: a.bio };
309
+ });
310
+ return { rows, rowCount: rows.length };
311
+ }
312
+
313
+ // THE GUILD PAGE's reads. The public guild: its WHERE is read, not assumed.
314
+ if (/^SELECT id, slug, name, description, created_at FROM platform_identity_guilds WHERE /.test(sql)) {
315
+ if (!/WHERE lower\(slug\) = \$1 AND visibility = 'public'$/.test(sql)) throw new Error(`fake pool: the public guild read moved — ${sql}`);
316
+ const rows = GUILDS.filter((g) => g.slug === String(params[0]) && g.visibility === 'public').map(({ visibility, ...g }) => g);
317
+ return { rows, rowCount: rows.length };
318
+ }
319
+ // ... a member's own row in a guild (the own-scope door).
320
+ if (/FROM platform_identity_guilds g JOIN platform_identity_guild_members m ON m\.guild_id = g\.id AND m\.github_id = \$2::bigint WHERE lower\(g\.slug\) = \$1$/.test(sql)) {
321
+ const g = GUILDS.find((x) => x.slug === String(params[0]));
322
+ const m = g && GUILD_MEMBERS.find((x) => x.guild_id === g.id && x.github_id === String(params[1]));
323
+ const rows = m ? [{ ...g, ...m, joined_at: 'T2' }] : [];
324
+ return { rows, rowCount: rows.length };
325
+ }
326
+ // ... both rosters: public (shown AND visible) and members' (active).
327
+ if (/FROM platform_identity_guild_members m JOIN platform_identity_accounts pa/.test(sql)) {
328
+ // the projection is pinned: a new column here is a new disclosure, and this fails first
329
+ const cols = (/^SELECT (.+?) FROM /.exec(sql) || [, ''])[1];
330
+ if (cols !== 'pa.handle, pa.display_name, pa.avatar_url, m.membership_kind') throw new Error(`fake pool: the guild roster selects something new — ${cols}`);
331
+ const keep = guildRosterFilter(sql, params);
332
+ const rows = GUILD_MEMBERS.filter(keep).map((m) => {
333
+ const a = ACCOUNTS.find((x) => x.github_id === m.github_id);
334
+ return { handle: a.handle, display_name: a.display_name, avatar_url: a.avatar_url, membership_kind: m.membership_kind };
335
+ });
336
+ return { rows, rowCount: rows.length };
337
+ }
338
+ // ... and the weekly totals: never computed on a read (guild-totals.js); none stored here.
339
+ if (/FROM platform_identity_guild_totals WHERE guild_id = \$1$/.test(sql)) return { rows: [], rowCount: 0 };
340
+
250
341
  // THE LEADERBOARD's totals: the rollup floor sits on the act CTE's join.
251
342
  if (/platform_identity_activity_snapshots/.test(sql)) {
252
343
  const where = lift(sql, /JOIN platform_identity_accounts pa ON pa\.github_id = a\.github_id WHERE (.+?) \), per_project AS/, 'leaderboard totals');
@@ -346,6 +437,8 @@ const ANON = null;
346
437
  const STRANGER = { name: 'Builder', github_id: '7100001', rank: 'xenos', curate: false };
347
438
  const RECRUITER = { name: 'Recruiter', github_id: '7100002', rank: 'archon', curate: true };
348
439
  const selfOf = (k) => ({ name: 'Self', github_id: ACCT[k].github_id, rank: 'xenos', curate: false });
440
+ // a fellow guild member: PUB's session, which owns both fixture guilds
441
+ const MEMBER = { name: 'Member', github_id: ACCT.PUB.github_id, rank: 'xenos', curate: false };
349
442
 
350
443
  const moduleApiPath = require.resolve('../src/module-api.js');
351
444
  const helpers = require('../src/bongos/routes/_helpers.js');
@@ -366,6 +459,13 @@ require.cache[moduleApiPath] = {
366
459
  },
367
460
  requirePermission: () => (req, res, next) => (viewer && viewer.curate ? next() : res.status(403).json({ ok: false, error: 'forbidden' })),
368
461
  requireRank: (...allowed) => (req, res, next) => (viewer && allowed.includes(viewer.rank) ? next() : res.fail('rank_forbidden', 403)),
462
+ // routes/projects.js (POST /projects/invite-guild): its seams are already
463
+ // registered as far as this file is concerned, and no provisioning module
464
+ // answers the inviter gate's ownership arm, so the rank door decides.
465
+ hasProvider: () => true,
466
+ registerProvider: () => {},
467
+ resolveOptional: () => null,
468
+ publicProjectFeedRateLimit: (_req, _res, next) => next(),
369
469
  },
370
470
  };
371
471
 
@@ -413,7 +513,7 @@ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
413
513
  const app = express();
414
514
  app.use(express.json());
415
515
  app.use(attachFail);
416
- for (const file of ['public-profile', 'profile', 'community-search', 'community-leaderboard', 'scouting']) {
516
+ for (const file of ['public-profile', 'profile', 'community-search', 'community-leaderboard', 'scouting', 'guilds-public', 'guilds', 'projects']) {
417
517
  app.use('/api/bongos', require(`../modules/platform-identity/routes/${file}.js`)());
418
518
  }
419
519
  const server = app.listen(0, '127.0.0.1');
@@ -445,6 +545,28 @@ function get(who, path_) {
445
545
  });
446
546
  }
447
547
 
548
+ /** A POST through the same recorder, so the sweeps below judge its body too. */
549
+ function post(who, path_, body) {
550
+ viewer = who;
551
+ const payload = JSON.stringify(body);
552
+ const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) };
553
+ return new Promise((resolve, reject) => {
554
+ const req = http.request({ host: '127.0.0.1', port, method: 'POST', path: path_, headers }, (res) => {
555
+ const chunks = [];
556
+ res.on('data', (c) => chunks.push(c));
557
+ res.on('end', () => {
558
+ const text = Buffer.concat(chunks).toString('utf8');
559
+ let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
560
+ const out = { who: who ? who.name : 'Anon', whoId: who ? who.github_id : null, path: path_, status: res.statusCode, text, json };
561
+ SEEN.push(out);
562
+ resolve(out);
563
+ });
564
+ });
565
+ req.on('error', reject);
566
+ req.end(payload);
567
+ });
568
+ }
569
+
448
570
  // ===========================================================================
449
571
  // 0. The instrument is calibrated before it is trusted
450
572
  // ===========================================================================
@@ -535,6 +657,78 @@ for (const k of KEYS) {
535
657
  });
536
658
  }
537
659
 
660
+ // The guild page's two rosters (task 1002301; ADR 0336 D4). One read per viewer
661
+ // serves every row, so each row's cell is checked against the same bodies.
662
+ const GUILD_PATH = '/api/bongos/guilds/mx-guild';
663
+ const ROSTER_KEYS = ['avatar_url', 'display_name', 'handle', 'membership_kind'];
664
+ const ALL_VIEWERS = () => [ANON, STRANGER, RECRUITER, MEMBER, ...KEYS.map(selfOf)];
665
+
666
+ test('the guild page’s public roster — the same bytes to Anon, Builder, Recruiter, a Member and every Self; listed or absent per the matrix', async () => {
667
+ const seen = [];
668
+ for (const who of ALL_VIEWERS()) seen.push(await get(who, GUILD_PATH));
669
+ for (const r of seen.slice(1)) {
670
+ assert.equal(r.status, seen[0].status, `${r.who} got a different status — the public guild read must not vary by viewer (ADR 0336 D4)`);
671
+ assert.equal(r.text, seen[0].text, `${r.who} got a different body — the public guild read must not vary by viewer (ADR 0336 D4)`);
672
+ }
673
+ const [r] = seen;
674
+ assert.equal(r.status, 200);
675
+ const listed = new Set(r.json.members.map((m) => m.handle));
676
+ for (const k of KEYS) assert.equal(listed.has(ACCT[k].handle), MATRIX[k].guild === 'listed', `${k} on the public roster: expected ${MATRIX[k].guild}`);
677
+ assert.equal(r.json.size, r.json.members.length, 'size is the roster’s own length — never a "+N more" (ADR 0336 D4)');
678
+ for (const m of r.json.members) assert.deepEqual(Object.keys(m).sort(), ROSTER_KEYS, 'handle, name, avatar, kind — never a GitHub id or login');
679
+ });
680
+
681
+ test('the guild members’ roster — a fellow member sees every active member, private ones included; Anon is refused', async () => {
682
+ const r = await get(MEMBER, `${GUILD_PATH}/members`);
683
+ assert.equal(r.status, 200);
684
+ const listed = new Set(r.json.members.map((m) => m.handle));
685
+ for (const k of KEYS) assert.equal(listed.has(ACCT[k].handle), MATRIX[k].members === 'listed', `${k} on the members’ roster: expected ${MATRIX[k].members}`);
686
+ assert.equal(r.json.count, r.json.members.length);
687
+ for (const m of r.json.members) assert.deepEqual(Object.keys(m).sort(), ROSTER_KEYS);
688
+ assert.deepEqual(Object.keys(r.json.you).sort(), ['counted_in_totals', 'membership_kind', 'shown_publicly'], 'the reader’s own two switches, and nobody else’s');
689
+ assert.equal((await get(ANON, `${GUILD_PATH}/members`)).status, 401);
690
+ });
691
+
692
+ test('an unlisted guild’s public read is a 404 to everyone, its own members included, byte-identical to a slug nobody holds; members reach it through the own-scope door', async () => {
693
+ const none = await get(ANON, '/api/bongos/guilds/mx-nobody');
694
+ assert.equal(none.status, 404);
695
+ for (const who of ALL_VIEWERS()) {
696
+ const r = await get(who, '/api/bongos/guilds/mx-unlisted');
697
+ assert.equal(r.status, 404, `${r.who}: an unlisted guild has no public existence (ADR 0336 D4)`);
698
+ assert.equal(r.text.replaceAll('mx-unlisted', 'S'), none.text.replaceAll('mx-nobody', 'S'), `${r.who}: no existence oracle`);
699
+ }
700
+ const own = await get(MEMBER, '/api/bongos/guilds/mx-unlisted/members');
701
+ assert.equal(own.status, 200);
702
+ const listed = new Set(own.json.members.map((m) => m.handle));
703
+ for (const k of KEYS) assert.equal(listed.has(ACCT[k].handle), MATRIX[k].members === 'listed', `${k} on the unlisted guild’s members’ roster`);
704
+ });
705
+
706
+ // A project invites a guild (task 1002303; ADR 0365 D3). The INVITER is the
707
+ // viewer here — a holder of the route's rank door (the Recruiter fixture carries
708
+ // it); the cell is who they are handed a link for.
709
+ const INVITE_GUILD_PATH = '/api/bongos/projects/invite-guild';
710
+ const INVITE_KEYS = ['display_name', 'handle', 'invite_url'];
711
+
712
+ test('a project invites a guild — one hall link per PUBLIC-roster member, listed or absent per the matrix; a non-inviter is refused', async () => {
713
+ const r = await post(RECRUITER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-guild' });
714
+ assert.equal(r.status, 200);
715
+ assert.equal(r.json.invited, false, 'the hub writes no invite (ADR 0353)');
716
+ const listed = new Set(r.json.invites.map((i) => i.handle));
717
+ for (const k of KEYS) assert.equal(listed.has(ACCT[k].handle), MATRIX[k].invite === 'listed', `${k} in the invite fan-out: expected ${MATRIX[k].invite}`);
718
+ assert.equal(r.json.count, r.json.invites.length, 'count is the array’s own length');
719
+ for (const i of r.json.invites) {
720
+ assert.deepEqual(Object.keys(i).sort(), INVITE_KEYS, 'a handle, a name and a link — never a GitHub id, a bio or a number');
721
+ const a = ACCOUNTS.find((x) => x.handle === i.handle);
722
+ assert.equal(i.invite_url, `${MX_CLIENT.origin}/builders/watch?invite=${encodeURIComponent(a.github_login)}`, 'the project’s own hall, with the login a public profile already shows');
723
+ }
724
+ const stranger = await post(STRANGER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-guild' });
725
+ assert.equal(stranger.status, 403, 'only the project’s inviter is handed the roster');
726
+ const unlisted = await post(RECRUITER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-unlisted' });
727
+ const nobody = await post(RECRUITER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-nobody' });
728
+ assert.equal(unlisted.status, 404);
729
+ assert.equal(unlisted.text, nobody.text, 'an unlisted guild cannot be invited: the unknown-guild 404, byte for byte');
730
+ });
731
+
538
732
  test('the sliver’s key lists are D3’s, transcribed: handle, name, avatar, disciplines, a recent-activity boolean — or the first three', () => {
539
733
  // The cells above compare a response to the MODULE's lists; this pins those
540
734
  // lists to the spec, so a sixth key added to the module cannot pass with them.
@@ -594,6 +788,10 @@ test('the recruiting directory — the recruiter sees the full and name-only tie
594
788
  // ===========================================================================
595
789
 
596
790
  const isOwnRead = (r, a) => r.whoId === a.github_id && r.path === '/api/bongos/me/cross-project';
791
+ // The one place ADR 0336 D4 lets a private member be named: their own guild's
792
+ // members' roster, read by a fellow member — joining is consent to be seen BY THE
793
+ // GUILD, never by the world. A provisional account is not on it (accountActiveSql).
794
+ const isFellowMemberRead = (r) => r.who === 'Member' && /^\/api\/bongos\/guilds\/[^/?]+\/members$/.test(r.path);
597
795
  // What a response DISCLOSES: its body minus the fields that only echo the
598
796
  // caller's own request back (search's `query` and `discipline`). A discipline
599
797
  // the caller typed into the URL is not the server revealing it; the results
@@ -608,7 +806,7 @@ function disclosed(r) {
608
806
  }
609
807
 
610
808
  test('the walk covered every surface for every row (so the claims below are about something)', () => {
611
- for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/scouting']) {
809
+ for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/scouting', '/guilds/mx-guild', '/guilds/mx-guild/members', '/projects/invite-guild']) {
612
810
  assert.ok(SEEN.some((r) => r.path.includes(p) && r.status === 200), `no 200 from ${p} — the sweep below would be vacuous for it`);
613
811
  }
614
812
  assert.ok(SEEN.length >= KEYS.length * 6, `only ${SEEN.length} responses recorded`);
@@ -630,21 +828,25 @@ test('hide_stats beats everything: no hidden account’s numbers reach anyone bu
630
828
  assert.ok(SEEN.some((r) => r.who === 'Anon' && r.text.includes(String(ROLLUP[ACCT.PUB.github_id].credits))), 'a shown record must reach Anon, or absence above is vacuous');
631
829
  });
632
830
 
633
- test('provisional and private (not discoverable) are absent from every cross-project surface, for every viewer but Self', () => {
831
+ test('provisional and private (not discoverable) are absent from every cross-project surface, for every viewer but Self — a private member’s own guild aside', () => {
634
832
  for (const k of ['PROV', 'PRIV']) {
635
833
  const a = ACCT[k];
636
- for (const r of SEEN.filter((x) => !isOwnRead(x, a) && !x.path.endsWith(`/profiles/${a.handle}`) && !x.path.endsWith(`/scouting/${a.handle}`))) {
834
+ const ownGuild = (x) => k === 'PRIV' && isFellowMemberRead(x);
835
+ for (const r of SEEN.filter((x) => !isOwnRead(x, a) && !ownGuild(x) && !x.path.endsWith(`/profiles/${a.handle}`) && !x.path.endsWith(`/scouting/${a.handle}`))) {
637
836
  for (const s of identitySentinels(a)) assert.ok(!disclosed(r).includes(s), `${k}'s "${s}" reached ${r.who} through GET ${r.path}`);
638
837
  }
639
838
  }
640
839
  });
641
840
 
642
- test('private · discoverable reaches the recruiter’s sliver and nowhere else', () => {
841
+ test('private · discoverable reaches the recruiter’s sliver and, as a member, its own guilds’ members’ rosters — nowhere else', () => {
643
842
  for (const k of ['PRIVD', 'PRIVDH']) {
644
843
  const a = ACCT[k];
645
844
  const leaks = SEEN.filter((r) => !isOwnRead(r, a) && identitySentinels(a).some((s) => disclosed(r).includes(s)));
646
- assert.deepEqual(leaks.map((r) => `${r.who} ${r.path}`), [`Recruiter /api/bongos/scouting/${a.handle}`],
647
- `${k} may be named only by the sliver, to the recruiter (D3)`);
845
+ assert.deepEqual([...new Set(leaks.map((r) => `${r.who} ${r.path}`))].sort(), [
846
+ 'Member /api/bongos/guilds/mx-guild/members',
847
+ 'Member /api/bongos/guilds/mx-unlisted/members',
848
+ `Recruiter /api/bongos/scouting/${a.handle}`,
849
+ ], `${k} may be named only by the sliver, to the recruiter (D3), and to its fellow guild members (ADR 0336 D4)`);
648
850
  }
649
851
  });
650
852
 
@@ -29,6 +29,10 @@
29
29
  // • a refusal never costs the project or the step — the box stays, re-tryable
30
30
  // • Skip advances with ZERO calls
31
31
  // • both call sites hand over a slug only for a signed-in owner
32
+ // • a GUILD can be invited from the project's own page (task 1002303, R27; ADR
33
+ // 0365 D3): exactly { client_id, guild } to /projects/invite-guild, its answer
34
+ // drawn as the same hand-off lines, one per member on the guild's public page,
35
+ // an unknown or unlisted guild one plain line, and the wizard has no such door
32
36
  //
33
37
  // Run: node --test --test-reporter=tap tests/project_invite_ui.mjs
34
38
 
@@ -51,7 +55,8 @@ assert.ok(START > 0, 'the invite region must start at INVITE_MOUNTS');
51
55
  assert.ok(END > START, 'the invite region must end before the boot block');
52
56
  const REGION = SCRIPT.slice(START, END);
53
57
  for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function skipInvite(', 'function inviteRefusal(',
54
- 'function setInviteSuggestions(', 'function renderInviteSugg(', 'function loadInviteSuggestions(']) {
58
+ 'function setInviteSuggestions(', 'function renderInviteSugg(', 'function loadInviteSuggestions(',
59
+ 'function doInviteGuild(', 'function inviteGuildSlug(']) {
55
60
  assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
56
61
  }
57
62
 
@@ -116,7 +121,7 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
116
121
  ['wizInvite', 'wizInviteWho', 'wizInviteList', 'wizInviteMsg', 'wizInviteLater', 'doneStepsHead',
117
122
  'wizInviteWhy', 'wizInviteSugg',
118
123
  'mInvite', 'mInviteWho', 'mInviteList', 'mInviteMsg',
119
- 'mInviteWhy', 'mInviteSugg'].forEach($);
124
+ 'mInviteWhy', 'mInviteSugg', 'mInviteGuild', 'mInviteGuildMsg'].forEach($);
120
125
 
121
126
  const queued = answers.slice();
122
127
  const fetch = (url, init = {}) => {
@@ -150,7 +155,7 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
150
155
 
151
156
  const mounts = {
152
157
  wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', why: 'wizInviteWhy', sugg: 'wizInviteSugg' },
153
- mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', why: 'mInviteWhy', sugg: 'mInviteSugg' },
158
+ mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', why: 'mInviteWhy', sugg: 'mInviteSugg', guild: 'mInviteGuild', guildMsg: 'mInviteGuildMsg' },
154
159
  };
155
160
  const m = mounts[mount];
156
161
  const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
@@ -186,6 +191,22 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
186
191
  async suggest(payload) { sandbox.setInviteSuggestions(mount, payload); await settle(); },
187
192
  async load(instanceId) { sandbox.loadInviteSuggestions(mount, instanceId); await settle(); },
188
193
  async pick(login) { pickLogin = login; await press('data-invite-pick'); },
194
+ // ── the guild half (task 1002303, R27) ───────────────────────────────
195
+ guildField: () => $('mInviteGuild'),
196
+ guildMsg: () => $('mInviteGuildMsg'),
197
+ async inviteGuild(value) {
198
+ $('mInviteGuild').value = value;
199
+ const handlers = listeners.get(mount + ':click') || [];
200
+ assert.equal(handlers.length, 1, 'the guild button rides the mount’s one delegated click handler');
201
+ handlers[0]({ target: { closest: (sel) => (sel === '[data-invite-guild]' ? {} : null) } });
202
+ await settle();
203
+ },
204
+ async guildEnter() {
205
+ const handlers = listeners.get('mInviteGuild:keydown') || [];
206
+ assert.equal(handlers.length, 1, 'the guild field takes Enter');
207
+ handlers[0]({ key: 'Enter', preventDefault() {} });
208
+ await settle();
209
+ },
189
210
  async enter() {
190
211
  const handlers = listeners.get(m.who + ':keydown') || [];
191
212
  assert.equal(handlers.length, 1, 'the field takes Enter');
@@ -708,3 +729,166 @@ test('the suggestion surface is mounted on BOTH the wizard and the project page'
708
729
  assert.match(COSMOS, /\.suggList\{/, 'styled in cosmos.css like its sibling list');
709
730
  assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>') + 1), /suggList/, 'not in the page head');
710
731
  });
732
+
733
+ // ── invite a guild (task 1002303, R27; ADR 0365 D3) ──────────────────────────
734
+ // The project's own page names a public guild; the route records that the project
735
+ // invited it and answers one hall hand-off link per member on the guild's PUBLIC
736
+ // page — the people the inviter could see. Nothing is sent from the hub: each
737
+ // becomes the same pending line a single invite gets, finished on the hall.
738
+
739
+ const guildAnswer = (members = [['mara-quinn', 'Mara Quinn'], ['kip', null]], name = 'Forge Crew') => ({
740
+ ok: true, status: 200,
741
+ body: {
742
+ invited: false,
743
+ guild: { slug: 'forge-crew', name },
744
+ invites: members.map(([handle, display]) => ({ handle, display_name: display, invite_url: 'https://mercury.cloudbongos.test/builders/watch?invite=' + handle })),
745
+ count: members.length,
746
+ },
747
+ });
748
+
749
+ test('inviteGuildSlug reads a guild the ways a person pastes one: its name, /g/<name>, or the page’s whole address', () => {
750
+ const f = boot().sandbox.inviteGuildSlug;
751
+ assert.equal(f('forge-crew'), 'forge-crew');
752
+ assert.equal(f(' Forge-Crew '), 'forge-crew', 'trimmed and lowercased, as the slug rule is');
753
+ assert.equal(f('/g/forge-crew'), 'forge-crew');
754
+ assert.equal(f('g/forge-crew/'), 'forge-crew');
755
+ assert.equal(f('https://cloudbongos.com/g/Forge-Crew/?ref=x#members'), 'forge-crew');
756
+ assert.equal(f(''), '');
757
+ assert.equal(f(null), '');
758
+ });
759
+
760
+ test('a guild invite sends exactly { client_id, guild } to /projects/invite-guild, on the cookie session', async () => {
761
+ const ui = boot({ mount: 'mInvite', answers: [guildAnswer()] });
762
+ await ui.open('mercury');
763
+
764
+ await ui.inviteGuild('https://cloudbongos.com/g/forge-crew');
765
+
766
+ assert.equal(ui.requests.length, 1);
767
+ const req = ui.requests[0];
768
+ assert.equal(req.url, '/api/bongos/projects/invite-guild');
769
+ assert.equal(req.method, 'POST');
770
+ assert.equal(req.credentials, 'same-origin');
771
+ assert.deepEqual(JSON.parse(req.body), { client_id: 'mercury', guild: 'forge-crew' }, 'the slug the route reads, and nothing else');
772
+ });
773
+
774
+ test('the answer is one hand-off line per member on the guild’s public page — the same lines a single invite gets', async () => {
775
+ // a hand-off link that is not http(s) is never drawn, whoever it belongs to
776
+ const queued = guildAnswer([['mara-quinn', 'Mara Quinn'], ['kip', null]]);
777
+ queued.body.invites.push({ handle: 'bad', display_name: 'Bad Link', invite_url: 'javascript:alert(1)' });
778
+ const h = boot({ mount: 'mInvite', answers: [queued] });
779
+ await h.open('mercury');
780
+
781
+ await h.inviteGuild('forge-crew');
782
+
783
+ assert.deepEqual(h.rows().map((r) => r.login), ['Mara Quinn', 'kip'], 'the display name the guild page shows, else the handle');
784
+ assert.deepEqual(h.rows().map((r) => r.url), [
785
+ 'https://mercury.cloudbongos.test/builders/watch?invite=mara-quinn',
786
+ 'https://mercury.cloudbongos.test/builders/watch?invite=kip',
787
+ ], 'each link is the route’s own invite_url, verbatim');
788
+ for (const r of h.rows()) assert.match(r.said, /not sent yet/i, 'no line claims an invite the hall has not sent');
789
+ assert.doesNotMatch(h.sandbox.$('mInviteList').innerHTML, /javascript:/);
790
+ assert.equal(h.guildMsg().className, 'projMsg ok');
791
+ assert.equal(h.guildMsg().textContent, 'Forge Crew: 2 members to invite. Send each invite from your project’s hall — every line above opens it with their name filled in.');
792
+ assert.equal(h.guildField().value, '', 'the field clears for the next one');
793
+ assert.equal(h.msg().textContent, '', 'the single-invite line is left alone');
794
+ });
795
+
796
+ test('inviting the same guild twice draws no second line for anyone already on one', async () => {
797
+ const ui = boot({ mount: 'mInvite', answers: [guildAnswer(), guildAnswer()] });
798
+ await ui.open('mercury');
799
+
800
+ await ui.inviteGuild('forge-crew');
801
+ await ui.inviteGuild('forge-crew');
802
+
803
+ assert.deepEqual(ui.rows().map((r) => r.login), ['Mara Quinn', 'kip']);
804
+ });
805
+
806
+ test('a guild with nobody on its public page says so plainly, and draws nothing', async () => {
807
+ const ui = boot({ mount: 'mInvite', answers: [guildAnswer([])] });
808
+ await ui.open('mercury');
809
+
810
+ await ui.inviteGuild('forge-crew');
811
+
812
+ assert.equal(ui.rows().length, 0);
813
+ assert.equal(ui.guildMsg().className, 'projMsg', 'an answer, not a failure');
814
+ assert.equal(ui.guildMsg().textContent, 'Nobody is listed on Forge Crew’s public page yet, so there is no one to invite from here.');
815
+ });
816
+
817
+ test('an unknown guild and an unlisted one are ONE plain line; the other refusals keep the widget’s vocabulary', async () => {
818
+ const ui = boot({ mount: 'mInvite', answers: [
819
+ refused(404, 'guild_not_found', 'guild_not_found'),
820
+ refused(403, 'permission_forbidden', 'requires project.curate'),
821
+ refused(401, 'unauthenticated'),
822
+ { reject: true },
823
+ refused(429, 'rate_limited', 'Too many invitations right now — give it a minute.'),
824
+ ] });
825
+ await ui.open('mercury');
826
+
827
+ await ui.inviteGuild('night-shift');
828
+ assert.equal(ui.guildMsg().textContent, 'No public guild at that name.');
829
+ assert.equal(ui.guildMsg().className, 'projMsg err');
830
+ assert.equal(ui.guildField().value, 'night-shift', 'a refusal keeps what was typed');
831
+
832
+ await ui.inviteGuild('forge-crew');
833
+ assert.match(ui.guildMsg().textContent, /can’t invite/i, 'the 403 said the careful way (ADR 0248)');
834
+ assert.doesNotMatch(ui.guildMsg().textContent, /project\.curate/);
835
+
836
+ await ui.inviteGuild('forge-crew');
837
+ assert.match(ui.guildMsg().innerHTML, /sign in again/i);
838
+
839
+ await ui.inviteGuild('forge-crew');
840
+ assert.match(ui.guildMsg().textContent, /reach the platform/i);
841
+
842
+ await ui.inviteGuild('forge-crew');
843
+ assert.match(ui.guildMsg().textContent, /Too many invitations right now/);
844
+ assert.equal(ui.rows().length, 0, 'no refusal draws a line');
845
+ });
846
+
847
+ test('an empty guild field is a question rather than a request; Enter in the field sends too', async () => {
848
+ const ui = boot({ mount: 'mInvite', answers: [guildAnswer()] });
849
+ await ui.open('mercury');
850
+
851
+ await ui.inviteGuild(' ');
852
+ assert.equal(ui.requests.length, 0);
853
+ assert.equal(ui.guildMsg().textContent, 'Type the guild’s name or its /g/ address.');
854
+ assert.ok(ui.focused.includes('mInviteGuild'), 'focus goes to the field it asks about');
855
+
856
+ ui.guildField().value = 'forge-crew';
857
+ await ui.guildEnter();
858
+ assert.equal(ui.requests.length, 1);
859
+ });
860
+
861
+ test('the guild door is the project page’s alone: the wizard’s step has none, and a closed mount sends nothing', async () => {
862
+ const wiz = boot({ mount: 'wizInvite' });
863
+ await wiz.open('mercury');
864
+ await wiz.inviteGuild('forge-crew');
865
+ assert.equal(wiz.requests.length, 0, 'the wizard mount names no guild field');
866
+
867
+ const closed = boot({ mount: 'mInvite' });
868
+ await closed.open('');
869
+ await closed.inviteGuild('forge-crew');
870
+ assert.equal(closed.requests.length, 0, 'no project, no request');
871
+ });
872
+
873
+ test('a new project starts the guild line clean too', async () => {
874
+ const ui = boot({ mount: 'mInvite', answers: [refused(404, 'guild_not_found')] });
875
+ await ui.open('mercury');
876
+ await ui.inviteGuild('night-shift');
877
+ assert.notEqual(ui.guildMsg().textContent, '');
878
+
879
+ await ui.open('venus');
880
+ assert.equal(ui.guildMsg().textContent, '', 'the answer belonged to the project it was asked for');
881
+ });
882
+
883
+ test('the project’s own page carries the guild door: a field, its button, and the one-line why — as real markup', () => {
884
+ const sec = (HUB.match(/<div class="card mSec" id="mInvite">[\s\S]*?\n <\/div>/) || [''])[0];
885
+ assert.match(sec, /<div class="attach">\s*<label class="vh" for="mInviteGuild">Guild name or address<\/label>\s*<input type="text" id="mInviteGuild"/, 'the page’s own input+button idiom');
886
+ assert.match(sec, /data-invite-guild="mInvite"/);
887
+ assert.match(sec, /Each member gets their own invite, and only the members shown on the guild’s public page are listed\./);
888
+ assert.match(sec, /<div class="projMsg" id="mInviteGuildMsg" role="status" aria-live="polite"><\/div>/, 'its answer has a line of its own, under the button');
889
+ assert.ok(sec.indexOf('id="mInviteGuild"') > sec.indexOf('id="mInviteList"'), 'below the pending lines it fills — "every line above"');
890
+ const panel = (HUB.match(/<section class="panel" id="panel-done"[\s\S]*?<\/section>/) || [''])[0];
891
+ assert.doesNotMatch(panel, /data-invite-guild/, 'the wizard’s step invites people, not guilds');
892
+ const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
893
+ assert.ok(states['in-manage-invite-guild'], 'the guild lines have a kit state');
894
+ });
@@ -24,8 +24,8 @@ test('a shared asset reaches every page in its dir whose HTML names it', () => {
24
24
  // sky-data.js is shared by the two doors of one page (task 1004232): /thinking and /goal-map.
25
25
  assert.deepEqual(names(['modules/hall-ui/public/sky-data.js']), ['goal-map.states.json', 'thinking.states.json']);
26
26
  assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goals.states.json']);
27
- // the account page links the hub's sheet too since task 1004259
28
- assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'projects.states.json']);
27
+ // the account page links the hub's sheet too since task 1004259, and the guild page since task 1002301
28
+ assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'guild.states.json', 'projects.states.json']);
29
29
  });
30
30
 
31
31
  test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {
@@ -34,7 +34,7 @@ test('the platform floors are a pinned list — a floor cannot vanish quietly',
34
34
  });
35
35
 
36
36
  test('the shipped states files validate and sit beside the page they name', () => {
37
- for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/projects-empty.states.json', 'modules/public-landing/public/projects-dns-pending.states.json', 'modules/public-landing/public/projects-hall-live.states.json','modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json', 'modules/public-landing/public/account-private.states.json', 'modules/hall-ui/public/ranks.states.json']) {
37
+ for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/projects-empty.states.json', 'modules/public-landing/public/projects-dns-pending.states.json', 'modules/public-landing/public/projects-hall-live.states.json','modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json', 'modules/public-landing/public/account-private.states.json', 'modules/public-landing/public/guild.states.json', 'modules/hall-ui/public/ranks.states.json']) {
38
38
  const json = readJson(rel);
39
39
  const v = lib.validateStatesFile(json);
40
40
  assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
@@ -43,14 +43,14 @@ test('the shipped states files validate and sit beside the page they name', () =
43
43
  }
44
44
  // the apex is dark only (task 1003336): its states files name the one mode, and
45
45
  // render.js reads it as the default so a plain `--page /` never renders light
46
- for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json']) {
46
+ for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json', 'modules/public-landing/public/guild.states.json']) {
47
47
  assert.deepEqual(readJson(rel).modes, ['dark'], `${rel}: the apex renders dark only`);
48
48
  assert.equal(readJson(rel).modeQuery, false, `${rel}: no query walks a second mode`);
49
49
  }
50
50
  });
51
51
 
52
52
  test('the shipped probes files validate', () => {
53
- for (const rel of ['modules/public-landing/public/projects.probes.json', 'modules/public-landing/public/index.probes.json', 'modules/public-landing/public/account.probes.json']) {
53
+ for (const rel of ['modules/public-landing/public/projects.probes.json', 'modules/public-landing/public/index.probes.json', 'modules/public-landing/public/account.probes.json', 'modules/public-landing/public/guild.probes.json']) {
54
54
  const v = lib.validateProbesFile(readJson(rel));
55
55
  assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
56
56
  }