@bongos/core 1.21.57 → 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 (47) hide show
  1. package/.bongos-core.json +82 -47
  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 +360 -352
  13. package/docs/copy-registry.json +479 -397
  14. package/docs/file-map.md +3 -0
  15. package/docs/module-api-changelog.md +2 -0
  16. package/docs/page-inventory.json +7 -1
  17. package/docs/page-readings.json +828 -811
  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 +64 -2
  28. package/modules/public-landing/public/guild.probes.json +13 -3
  29. package/modules/public-landing/public/guild.states.json +10 -5
  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/me__guild-engagements.json +7 -0
  34. package/modules/ui-design/kit/serve.js +54 -5
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +6 -0
  38. package/scripts/gds/run-unit-tests.js +5 -0
  39. package/src/module-api.js +1 -1
  40. package/tests/applicant_profile_boundary.mjs +104 -1
  41. package/tests/application_echo.mjs +91 -5
  42. package/tests/guild_engagement.mjs +881 -0
  43. package/tests/guild_engagement_db.mjs +240 -0
  44. package/tests/hub_engagement_cards.mjs +307 -0
  45. package/tests/hub_guild_page.mjs +94 -7
  46. package/tests/privacy_matrix.mjs +99 -9
  47. package/tests/project_invite_ui.mjs +187 -3
@@ -3,10 +3,12 @@
3
3
  //
4
4
  // A guild spans projects, so its page is the hub's. One shell serves every slug;
5
5
  // the page asks the public read (GET /guilds/:slug) and, for someone signed in,
6
- // the members' own-scope read (GET /guilds/:slug/members), and writes one thing:
7
- // the member's own two switches (PATCH /guilds/:slug/membership). These pin:
6
+ // the members' own-scope read (GET /guilds/:slug/members), and writes two things:
7
+ // the member's own two switches (PATCH /guilds/:slug/membership) and, for the
8
+ // owner only, the guild applying to a project (POST /guilds/:slug/engagements,
9
+ // task 1002303, R27). These pin:
8
10
  // 1. the page as a file: portable, dark only, noindex, no page :root, no
9
- // innerHTML, no GitHub id or login read, and only the three guild routes;
11
+ // innerHTML, no GitHub id or login read, and only the four guild routes;
10
12
  // 2. its slug rule is the module's (a boundary-forced copy);
11
13
  // 3. the bar is the hub's — the account page's chrome, with no current place
12
14
  // and the sign-in doors pointed back at the guild by the script;
@@ -15,7 +17,9 @@
15
17
  // 10"), "no guild here" as ONE answer for an unknown slug, an unlisted guild
16
18
  // and a malformed path, the members' view, the prompt after a guild goes
17
19
  // public (asked, never assumed; "not now" remembered per guild), the
18
- // counted switch and its notice, and each save sending only what moved;
20
+ // counted switch and its notice, each save sending only what moved, and
21
+ // the owner's "apply to a project" (shown to the owner alone, one honest
22
+ // line for a project that is not public on the map);
19
23
  // 5. the route: /g/<slug> on the hub's apex only, never on a child or the
20
24
  // builders/status hosts, and a segment that cannot be a slug falls through.
21
25
  // What the public roster may hold is the server's, and tests/privacy_matrix.mjs
@@ -67,7 +71,7 @@ test('portable and dark only: stamped title, noindex, balanced sentinels, no :ro
67
71
  test('the script builds no markup from strings, never reads a GitHub id or login, and the regions ship hidden', () => {
68
72
  assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML/, 'live DOM is textContent / createElement only');
69
73
  assert.doesNotMatch(SCRIPT, /github_(id|login)/, 'a roster shows handle, display name, avatar and kind — never a GitHub id or login (ADR 0336 D4)');
70
- for (const id of ['guildBody', 'missing', 'missingSignin', 'gYou', 'showAsk', 'shownUnlisted', 'gRosterEmpty']) {
74
+ for (const id of ['guildBody', 'missing', 'missingSignin', 'gYou', 'showAsk', 'shownUnlisted', 'gRosterEmpty', 'gApply']) {
71
75
  assert.match(PAGE, new RegExp(`id="${id}"[^>]*\\bhidden\\b`), `#${id} ships hidden`);
72
76
  }
73
77
  for (const [name, values] of [['shown', ['on', 'off']], ['counted', ['on', 'off']]]) {
@@ -76,15 +80,16 @@ test('the script builds no markup from strings, never reads a GitHub id or login
76
80
  for (const id of ['askYes', 'askNo', 'youSave', 'gRoster', 'tShipped', 'tCredits', 'tKarma', 'tProjects']) assert.match(PAGE, new RegExp(`id="${id}"`));
77
81
  });
78
82
 
79
- test('the page calls only the three guild routes; the bar’s two calls stay in the shared asset', () => {
83
+ test('the page calls only the four guild routes; the bar’s two calls stay in the shared asset', () => {
80
84
  const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST|DELETE)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
81
- assert.deepEqual([...new Set(calls)].sort(), ['GET /guilds/', 'PATCH /guilds/'].sort(), 'every call is built on /guilds/<slug>');
85
+ assert.deepEqual([...new Set(calls)].sort(), ['GET /guilds/', 'PATCH /guilds/', 'POST /guilds/'].sort(), 'every call is built on /guilds/<slug>');
82
86
  assert.deepEqual([...SCRIPT.matchAll(/request\('[A-Z]+', '\/guilds\/' \+ slug( \+ '[^']*')?/g)].map((m) => m[0]).sort(), [
83
87
  "request('GET', '/guilds/' + slug",
84
88
  "request('GET', '/guilds/' + slug",
85
89
  "request('GET', '/guilds/' + slug + '/members'",
86
90
  "request('GET', '/guilds/' + slug + '/members'",
87
91
  "request('PATCH', '/guilds/' + slug + '/membership'",
92
+ "request('POST', '/guilds/' + slug + '/engagements'",
88
93
  ].sort());
89
94
  assert.match(PAGE, /<script src="\/assets\/hub-bar\.js"><\/script>\n<script>\n\(function \(\) \{/, 'hub-bar.js runs first, so window.HubBar exists when the page script asks for it');
90
95
  assert.doesNotMatch(SCRIPT, /function (openBand|closeMenus|openMenu)\b|\/auth\/logout/, 'no second copy of the bands or sign out in the page');
@@ -488,6 +493,88 @@ test('an unlisted guild, to its members: no public page, so no pair, no prompt,
488
493
  'a visibility change flips only the owner’s own flag');
489
494
  });
490
495
 
496
+ // ── 4b. apply to a project, as this guild (task 1002303, R27; ADR 0365 D2) ──
497
+ // The owner files the guild's application; it applies for nobody, so each member
498
+ // (the owner included) applies as themselves from their invitations. A project
499
+ // that is unknown, off the map or not public is ONE 404 on the server, and one
500
+ // line here that never says a private project exists.
501
+
502
+ function ownerAnswer(engage) {
503
+ const members = (m, p, body) => memberAnswer({ you: { membership_kind: 'owner' } })(m, p, body);
504
+ return (m, p, body) => (p === '/guilds/forge-crew/engagements' && m === 'POST' ? engage(body) : members(m, p, body));
505
+ }
506
+ const ENGAGED = (origin) => ({
507
+ status: 200,
508
+ data: { engagement: { guild: { slug: 'forge-crew', name: 'Forge Crew' }, kind: 'apply', project: { name: 'Tidepool', origin }, created_at: '2026-10-05T09:00:00.000Z' } },
509
+ });
510
+ const sendApply = (dom, value) => {
511
+ dom.el('applyOrigin').value = value;
512
+ for (const f of dom.el('applyForm')._on.submit || []) f({ preventDefault() {} });
513
+ };
514
+
515
+ test('apply to a project: shown to the guild’s owner alone — never to a member, a stranger or someone signed out', async () => {
516
+ const owner = boot({ me: ME, answer: ownerAnswer(() => NOT_FOUND) });
517
+ await settle();
518
+ assert.equal(owner.el('gApply').hidden, false, 'the owner files for the guild');
519
+
520
+ const member = boot({ me: ME, answer: memberAnswer({}) });
521
+ await settle();
522
+ assert.equal(member.el('gApply').hidden, true, 'a member applies from their own invitations, not here');
523
+
524
+ const stranger = boot({ me: ME, answer: (m, p) => (p === '/guilds/forge-crew' ? { status: 200, data: PUBLIC() } : NOT_FOUND) });
525
+ await settle();
526
+ assert.equal(stranger.el('gApply').hidden, true);
527
+
528
+ const out = boot({ answer: (m, p) => (p === '/guilds/forge-crew' ? { status: 200, data: PUBLIC() } : NOT_FOUND) });
529
+ await settle();
530
+ assert.equal(out.el('gApply').hidden, true);
531
+ });
532
+
533
+ test('apply to a project: sends exactly { origin } (a bare host taken as https://), and says it applied for nobody, the owner included', async () => {
534
+ const sent = [];
535
+ const dom = boot({ me: ME, answer: ownerAnswer((body) => { sent.push(body); return ENGAGED(body.origin); }) });
536
+ await settle();
537
+
538
+ sendApply(dom, ' ');
539
+ assert.equal(sent.length, 0, 'an empty field is a question, not a request');
540
+ assert.equal(dom.text('applyMsg'), 'Enter the project’s address.');
541
+ assert.equal(dom.el('applyMsg').className, 'projMsg err');
542
+
543
+ sendApply(dom, ' tidepool.example.com ');
544
+ await settle();
545
+ assert.deepEqual(sent, [{ origin: 'https://tidepool.example.com' }], 'the one field the route reads, and nothing else');
546
+ assert.deepEqual(dom.calls.filter((c) => c.method === 'POST').map((c) => c.path), ['/guilds/forge-crew/engagements']);
547
+ assert.equal(dom.text('applyMsg'), 'Done. Forge Crew’s members will see Tidepool in their invitations, and each one applies as themselves. That includes you — this didn’t apply for you.');
548
+ assert.equal(dom.el('applyMsg').className, 'projMsg ok');
549
+ assert.equal(dom.el('applyOrigin').value, '', 'the field clears for the next one');
550
+ assert.equal(dom.el('applySend').disabled, false);
551
+ assert.equal(dom.calls.filter((c) => c.path === '/my-projects/join').length, 0, 'filing never applies on anyone’s behalf');
552
+ });
553
+
554
+ test('apply to a project: a project that is not public on the map is ONE honest line; other refusals in the server’s words', async () => {
555
+ let answer = { status: 404, data: { error: { code: 'unknown_project', message: 'unknown_project' } } };
556
+ const dom = boot({ me: ME, answer: ownerAnswer(() => answer) });
557
+ await settle();
558
+
559
+ sendApply(dom, 'https://stealth.example.com');
560
+ await settle();
561
+ assert.equal(dom.text('applyMsg'), 'No public project on the map at that address.', 'unknown, private and stealth read the same');
562
+ assert.doesNotMatch(dom.text('applyMsg'), /private|stealth|exists/i);
563
+ assert.equal(dom.el('applyMsg').className, 'projMsg err');
564
+ assert.equal(dom.el('applyOrigin').value, 'https://stealth.example.com', 'a refusal keeps what was typed');
565
+
566
+ answer = { status: 403, data: { error: { code: 'guild_owner_only', message: 'only the guild owner can do that' } } };
567
+ sendApply(dom, 'https://tidepool.example.com');
568
+ await settle();
569
+ assert.equal(dom.text('applyMsg'), 'Only the guild owner can do that.');
570
+
571
+ answer = { status: 401, data: null };
572
+ sendApply(dom, 'https://tidepool.example.com');
573
+ await settle();
574
+ assert.equal(dom.text('applyMsg'), 'Your session expired — sign in again to send this.');
575
+ assert.equal(dom.el('who').hidden, true, 'the chip is repainted signed out');
576
+ });
577
+
491
578
  // ── 5. the route ─────────────────────────────────────────────────────────────
492
579
 
493
580
  const { buildPlatformApp } = require('../src/platform-server.js');
@@ -160,14 +160,19 @@ function identitySentinels(acct) {
160
160
  // members GET /guilds/:slug/members a fellow Member — the own-scope roster: 'listed' |
161
161
  // 'absent'. Joining is consent to be seen BY THE GUILD (D4 §1), so a private
162
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
163
168
  const MATRIX = {
164
- PROV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'n/a', guild: 'absent', members: 'absent' },
165
- PUB: { profile: 'full', search: 'listed', board: 'listed', directory: 'full', sliver: 'full', self: 'own', guild: 'listed', members: 'listed' },
166
- SHY: { profile: 'identity', search: 'name-only', board: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own', guild: 'listed', members: 'listed' },
167
- PRIV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'own', guild: 'absent', members: 'listed' },
168
- PRIVD: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'full', self: 'own', guild: 'absent', members: 'listed' },
169
- PRIVDH: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'name-only', self: 'own', guild: 'absent', members: 'listed' },
170
- OPTOUT: { profile: 'full', search: 'listed', board: 'listed', directory: 'absent', sliver: 404, self: 'own', guild: 'listed', members: 'listed' },
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' },
171
176
  };
172
177
 
173
178
  // ===========================================================================
@@ -271,10 +276,40 @@ function guildRosterFilter(sql, params) {
271
276
  return (m) => m.guild_id === String(params[0]) && (!shownOnly || m.shown_publicly) && byAccount(ACCOUNTS.find((a) => a.github_id === m.github_id));
272
277
  }
273
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
+
274
283
  const pool = {
275
284
  query: async (text, params = []) => {
276
285
  const sql = String(text).replace(/\s+/g, ' ').trim();
277
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
+
278
313
  // THE GUILD PAGE's reads. The public guild: its WHERE is read, not assumed.
279
314
  if (/^SELECT id, slug, name, description, created_at FROM platform_identity_guilds WHERE /.test(sql)) {
280
315
  if (!/WHERE lower\(slug\) = \$1 AND visibility = 'public'$/.test(sql)) throw new Error(`fake pool: the public guild read moved — ${sql}`);
@@ -424,6 +459,13 @@ require.cache[moduleApiPath] = {
424
459
  },
425
460
  requirePermission: () => (req, res, next) => (viewer && viewer.curate ? next() : res.status(403).json({ ok: false, error: 'forbidden' })),
426
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(),
427
469
  },
428
470
  };
429
471
 
@@ -471,7 +513,7 @@ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
471
513
  const app = express();
472
514
  app.use(express.json());
473
515
  app.use(attachFail);
474
- for (const file of ['public-profile', 'profile', 'community-search', 'community-leaderboard', 'scouting', 'guilds-public', 'guilds']) {
516
+ for (const file of ['public-profile', 'profile', 'community-search', 'community-leaderboard', 'scouting', 'guilds-public', 'guilds', 'projects']) {
475
517
  app.use('/api/bongos', require(`../modules/platform-identity/routes/${file}.js`)());
476
518
  }
477
519
  const server = app.listen(0, '127.0.0.1');
@@ -503,6 +545,28 @@ function get(who, path_) {
503
545
  });
504
546
  }
505
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
+
506
570
  // ===========================================================================
507
571
  // 0. The instrument is calibrated before it is trusted
508
572
  // ===========================================================================
@@ -639,6 +703,32 @@ test('an unlisted guild’s public read is a 404 to everyone, its own members in
639
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`);
640
704
  });
641
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
+
642
732
  test('the sliver’s key lists are D3’s, transcribed: handle, name, avatar, disciplines, a recent-activity boolean — or the first three', () => {
643
733
  // The cells above compare a response to the MODULE's lists; this pins those
644
734
  // lists to the spec, so a sixth key added to the module cannot pass with them.
@@ -716,7 +806,7 @@ function disclosed(r) {
716
806
  }
717
807
 
718
808
  test('the walk covered every surface for every row (so the claims below are about something)', () => {
719
- for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/scouting', '/guilds/mx-guild', '/guilds/mx-guild/members']) {
809
+ for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/scouting', '/guilds/mx-guild', '/guilds/mx-guild/members', '/projects/invite-guild']) {
720
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`);
721
811
  }
722
812
  assert.ok(SEEN.length >= KEYS.length * 6, `only ${SEEN.length} responses recorded`);
@@ -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
+ });