@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
@@ -26,6 +26,10 @@
26
26
  "in-manage-kind": { "auth": true, "url": "/projects?view=manage&id=101", "expect": {"visible":["#mKind","#mKindPick .modBand","#mKindWhat summary"],"count":[["#mKindPick .modOpt",5],["#mKindPick .modOpt[aria-pressed=\"true\"]",0]],"text":[["#mKindPick .modOpt b","Game"],["#mKindPick .finePrint","This project has never said what kind it is. Saying so puts the word beside its name wherever it is listed; it changes nothing else."]]} },
27
27
  "in-manage-invite": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mInvite", "#mInvite h2", "#mInviteWho", "#mInvite [data-invite-send]"] } },
28
28
  "in-manage-invite-handoff": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["fill","#mInviteWho","octo-builder"],["click","#mInvite [data-invite-send]"],["wait",400]], "expect": {"visible":["#mInviteList a","#mInviteMsg"],"hidden":[]} },
29
+ "in-manage-invite-guild": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["fill","#mInviteGuild","https://cloudbongos.com/g/forge-crew"],["click","#mInvite [data-invite-guild]"],["wait",400]], "expect": { "visible": ["#mInviteGuild", "#mInviteList a", "#mInviteGuildMsg"], "count": [["#mInviteList li", 4]], "value": [["#mInviteGuild", ""]], "hasClass": [["#mInviteGuildMsg", "ok"]], "text": [["#mInviteList li:first-child b", "Mara Quinn"], ["#mInviteGuildMsg", "Forge Crew: 4 members to invite. Send each invite from your project’s hall — every line above opens it with their name filled in."]] } },
30
+ "in-mine-engagements": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["focus","#engageList .engageRow:first-child .btn"],["wait",200]], "expect": { "visible": ["#mineInvites", "#invitesList", "#engageList"], "count": [["#engageList .engageRow", 2], ["#engageList .engageRow .mineJoinNote:not([hidden])", 0]], "text": [["#engageList .engageRow:nth-child(1) .mineRow__name", "Forge Crew is applying to Tidepool — apply with them?"], ["#engageList .engageRow:nth-child(1) .mineRow__host", "tidepool.cloudbongos.com"], ["#engageList .engageRow:nth-child(2) .mineRow__name", "Lantern Works invited Forge Crew"], ["#engageList .engageRow:nth-child(1) .mineRow__hint", "You apply as yourself — add a note for the owner if you like."], ["#engageList .engageRow:nth-child(1) .btn", "Apply"]] } },
31
+ "in-mine-engagement-note": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["click","#engageList .engageRow:first-child .linklike"],["fill","#engageList .engageRow:first-child .mineJoinNote","I like the boring parts too: migrations and the deploy path."],["wait",150]], "expect": { "visible": ["#engageList .engageRow:first-child .mineJoinNote"], "hidden": ["#engageList .engageRow:first-child .linklike"], "value": [["#engageList .engageRow:first-child .mineJoinNote", "I like the boring parts too: migrations and the deploy path."]] } },
32
+ "in-mine-engagement-applied": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["click","#engageList .engageRow:first-child .linklike"],["fill","#engageList .engageRow:first-child .mineJoinNote","I like the boring parts too: migrations and the deploy path."],["click","#engageList .engageRow:first-child .btn"],["wait",400]], "expect": { "hidden": ["#engageList .engageRow:first-child .mineJoinNote", "#engageList .engageRow:first-child .linklike"], "hasClass": [["#engageList .engageRow:first-child .projMsg", "ok"], ["#engageList .engageRow:first-child .btn", "ghost"]], "text": [["#engageList .engageRow:first-child .btn", "Applied"], ["#engageList .engageRow:first-child .projMsg", "Requested access. The project’s owner admits new builders; once approved, sign in to enter."], ["#engageList .engageRow:nth-child(2) .btn", "Apply"]] } },
29
33
  "in-manage-app-live": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStat","#mApp .appVars"],"hidden":[]} },
30
34
  "in-manage-app-building": { "auth": true, "url": "/projects?view=manage&id=102", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStages"],"hidden":[]} },
31
35
  "in-manage-app-failed": { "auth": true, "url": "/projects?view=manage&id=105", "actions": [["wait",900],["focus","#mApp .appRetryBtn"]], "expect": {"visible":["#mAppCard","#mApp p.err","#mApp .appRetryBtn"],"hidden":["#mApp .appRetry"]} },
@@ -0,0 +1,7 @@
1
+ {
2
+ "engagements": [
3
+ { "guild": { "slug": "forge-crew", "name": "Forge Crew" }, "kind": "apply", "project": { "name": "Tidepool", "origin": "https://tidepool.cloudbongos.com" }, "created_at": "2026-10-03T12:00:00.000Z" },
4
+ { "guild": { "slug": "forge-crew", "name": "Forge Crew" }, "kind": "invited", "project": { "name": "Lantern Works", "origin": "https://lantern-works.cloudbongos.com" }, "created_at": "2026-10-02T09:30:00.000Z" }
5
+ ],
6
+ "count": 2
7
+ }
@@ -111,9 +111,12 @@ const FEEDS = {
111
111
  // /founding/genesis and /government/constitution (task 1004423) ship no default file: the
112
112
  // genesis home asks for them only in founding mode, so every existing render is unchanged,
113
113
  // and the genesis variants point them at their samples.
114
+ // /me/guild-engagements (task 1002303, R27) is the mine view's guild cards: a guild applying
115
+ // to a project, and a project that invited a guild, for the guilds the builder is in.
114
116
  const AUTHED = {
115
117
  '/my-projects': 'my-projects', '/my-projects/invites': 'my-projects__invites', '/founding/builders': 'founding-builders',
116
118
  '/founding/genesis': 'founding-genesis', '/government/constitution': 'government-constitution',
119
+ '/me/guild-engagements': 'me__guild-engagements',
117
120
  };
118
121
  const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' } };
119
122
 
@@ -474,6 +477,47 @@ function guildMembershipPatch(res, slug, body) {
474
477
  return json(res, 200, { shown_publicly: g.members.you.shown_publicly, counted_in_totals: g.members.you.counted_in_totals });
475
478
  }
476
479
 
480
+ // Guild engagements (task 1002303, R27; ADR 0365). The stub mirrors the routes' refusals so
481
+ // a probe cannot pass on a body or a caller the server would refuse:
482
+ // POST /guilds/:slug/engagements { origin } the owner files "this guild is applying to P":
483
+ // a non-member's 404, a member's owner-only 403, and ONE 404 for a project that is
484
+ // unknown or not (on the map AND public) — here, a featured-feed row whose orb is a planet;
485
+ // POST /projects/invite-guild { client_id, guild } one hall hand-off link per member on the
486
+ // guild's PUBLIC roster; an unknown or unlisted guild is one 404;
487
+ // POST /my-projects/join's optional `guild` must name a held guild the builder is in with a
488
+ // held engagement (me__guild-engagements.json) for that project, else 409 and nothing relayed.
489
+ const UNKNOWN_PROJECT = { error: { code: 'unknown_project', message: 'unknown_project' } };
490
+ const sameOrigin = (a, b) => String(a || '').trim().toLowerCase().replace(/\/+$/, '') === String(b || '').trim().toLowerCase().replace(/\/+$/, '');
491
+ function heldFixture(name) {
492
+ return JSON.parse(fs.readFileSync(path.join(FIXTURES, (ALIAS[name] || name) + '.json'), 'utf8'));
493
+ }
494
+ function guildEngagementPost(res, slug, body) {
495
+ const g = guildsHeld()[String(slug).toLowerCase()];
496
+ if (!g || !g.members) return json(res, 404, GUILD_NOT_FOUND);
497
+ if (g.members.you.membership_kind !== 'owner') return refuse(res, 403, 'guild_owner_only', 'only the guild owner can do that');
498
+ const bad = Object.keys(body).find((k) => k !== 'origin');
499
+ if (bad) return refuse(res, 400, 'validation_failed', `unknown field: ${bad}`);
500
+ if (typeof body.origin !== 'string' || !body.origin || body.origin.length > 300) return refuse(res, 400, 'validation_failed', 'origin must be a string');
501
+ const p = (heldFixture('featured').projects || []).find((x) => x.orb === 'planet' && sameOrigin(x.origin, body.origin));
502
+ if (!p) return json(res, 404, UNKNOWN_PROJECT);
503
+ return json(res, 200, { engagement: { guild: { slug: g.members.guild.slug, name: g.members.guild.name }, kind: 'apply', project: { name: p.name, origin: p.origin }, created_at: new Date().toISOString() } });
504
+ }
505
+ function guildInvitePost(res, body) {
506
+ const bad = Object.keys(body).find((k) => !['client_id', 'guild'].includes(k));
507
+ if (bad) return refuse(res, 400, 'validation_failed', `unknown field: ${bad}`);
508
+ if (typeof body.client_id !== 'string' || !body.client_id) return refuse(res, 400, 'validation_failed', 'client_id must be a string');
509
+ if (typeof body.guild !== 'string' || !body.guild || body.guild.length > 39) return refuse(res, 400, 'validation_failed', 'guild must be a string of at most 39 characters');
510
+ const g = guildsHeld()[body.guild.toLowerCase()];
511
+ if (!g || !g.public) return json(res, 404, GUILD_NOT_FOUND);
512
+ const invites = (g.public.members || []).map((m) => ({ handle: m.handle, display_name: m.display_name || null, invite_url: 'https://mercury.example.test/builders/watch?invite=' + encodeURIComponent(m.handle) }));
513
+ return json(res, 200, { invited: false, guild: { slug: g.public.guild.slug, name: g.public.guild.name }, invites, count: invites.length });
514
+ }
515
+ function guildEngagementLive(slug, origin) {
516
+ const g = typeof slug === 'string' && guildsHeld()[slug.toLowerCase()];
517
+ if (!g || !g.members) return false;
518
+ return (heldFixture('me__guild-engagements').engagements || []).some((e) => e.guild && e.guild.slug === slug.toLowerCase() && e.project && sameOrigin(e.project.origin, origin));
519
+ }
520
+
477
521
  const server = http.createServer((req, res) => {
478
522
  const u = new URL(req.url, 'http://x');
479
523
  const p = u.pathname;
@@ -491,10 +535,11 @@ const server = http.createServer((req, res) => {
491
535
  if (rest === '/me/cross-project' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => crossProjectPatch(res, body)) : json(res, 401, UNAUTH);
492
536
  if (rest === '/me/profile' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => profilePatch(res, body)) : json(res, 401, UNAUTH);
493
537
  if (rest === '/me/handle' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => handlePatch(res, body)) : json(res, 401, UNAUTH);
494
- const guild = rest.match(/^\/guilds\/([^/]+)(\/members|\/membership)?$/);
538
+ const guild = rest.match(/^\/guilds\/([^/]+)(\/members|\/membership|\/engagements)?$/);
495
539
  if (guild && !guild[2] && req.method === 'GET') return guildRead(res, guild[1], 'public', origin);
496
540
  if (guild && guild[2] === '/members' && req.method === 'GET') return AUTH ? guildRead(res, guild[1], 'members', origin) : json(res, 401, UNAUTH);
497
541
  if (guild && guild[2] === '/membership' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => guildMembershipPatch(res, guild[1], body)) : json(res, 401, UNAUTH);
542
+ if (guild && guild[2] === '/engagements' && req.method === 'POST') return AUTH ? readBody(req, (body) => guildEngagementPost(res, guild[1], body)) : json(res, 401, UNAUTH);
498
543
  if (rest === '/provisioning/instances' && req.method === 'GET') return AUTH ? instances(res, null, false) : json(res, 401, UNAUTH);
499
544
  // the planet-physics questions' pre-create check (task 1004418): nothing stops here —
500
545
  // the kit carries no screen. Of the soft-limit table it mirrors only the three hide
@@ -533,10 +578,13 @@ const server = http.createServer((req, res) => {
533
578
  // decline receipts a real render or probe of the my-projects block drives,
534
579
  // so those buttons are not stuck picturing only their failure branch.
535
580
  if (rest === '/my-projects/join' && req.method === 'POST') {
536
- return AUTH ? readBody(req, (body) => json(res, 200, {
537
- ok: true, status: 'requested', project: { name: body.origin || 'a project', origin: body.origin || null },
538
- message: 'Requested access. The project’s owner admits new builders; once approved, sign in to enter.',
539
- })) : json(res, 401, UNAUTH);
581
+ return AUTH ? readBody(req, (body) => {
582
+ if (body.guild !== undefined && !guildEngagementLive(body.guild, body.origin)) return refuse(res, 409, 'guild_engagement_gone', 'that guild engagement has ended');
583
+ return json(res, 200, {
584
+ ok: true, status: 'requested', project: { name: body.origin || 'a project', origin: body.origin || null },
585
+ message: 'Requested access. The project’s owner admits new builders; once approved, sign in to enter.',
586
+ });
587
+ }) : json(res, 401, UNAUTH);
540
588
  }
541
589
  if (/^\/my-projects\/[^/]+\/leave$/.test(rest) && req.method === 'POST') return AUTH ? json(res, 200, { ok: true, left: true }) : json(res, 401, UNAUTH);
542
590
  if (/^\/my-projects\/invites\/[^/]+\/decline$/.test(rest) && req.method === 'POST') return AUTH ? json(res, 200, { ok: true, accepted: false }) : json(res, 401, UNAUTH);
@@ -549,6 +597,7 @@ const server = http.createServer((req, res) => {
549
597
  invite_url: 'https://mercury.example.test/builders/watch?invite=' + encodeURIComponent(body.github_login || ''),
550
598
  })) : json(res, 401, UNAUTH);
551
599
  }
600
+ if (rest === '/projects/invite-guild' && req.method === 'POST') return AUTH ? readBody(req, (body) => guildInvitePost(res, body)) : json(res, 401, UNAUTH);
552
601
  // the manage flows (task 1003345): every door on a row and on the manage page only ASKS
553
602
  // (ADR 0111) — the stub answers each ask with the route's receipt so the probes can drive
554
603
  // attach / restart / teardown / revive / join end to end; nothing changes in the fixture
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.57",
3
+ "version": "1.21.58",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.57",
9
+ "version": "1.21.58",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.57",
3
+ "version": "1.21.58",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9114,5 +9114,11 @@
9114
9114
  "id": "1002301",
9115
9115
  "text": "Guilds now have their own page on cloudbongos.com — who is in it and what it has shipped together — and members can choose whether they appear on it and count toward its totals."
9116
9116
  }
9117
+ ],
9118
+ "1.21.58": [
9119
+ {
9120
+ "id": "1002303",
9121
+ "text": "Guilds can now put themselves forward for a project, and a project can invite a whole guild — but every member still applies or accepts as themselves, so nobody joins a project without their own say."
9122
+ }
9117
9123
  ]
9118
9124
  }
@@ -183,6 +183,11 @@ const INTEGRATION = new Set([
183
183
  // proves the one-owner partial index, the expiry CTE drained before its insert,
184
184
  // the cascade on dissolve and the two-statement transfer.
185
185
  'guilds_db',
186
+ // task 1002303 (R27): guild engagements against real SQL — the FK that cascades
187
+ // a dissolved guild's engagements and the one that SETs NULL on the echo, the
188
+ // upsert restarting the 30-day window in place, and the joined_at rule. The
189
+ // DB-free sibling (guild_engagement) pins the routes and statement shapes.
190
+ 'guild_engagement_db',
186
191
  // task 1002302 (R26): a guild's weekly rounded totals against real SQL. Who is
187
192
  // counted (three predicates and a FILTERed k count) and the upsert's week guard,
188
193
  // re-evaluated against a racing writer's committed row, are planner facts; the
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.57'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.58'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -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