@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.
- package/.bongos-core.json +106 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +13 -2
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +2 -0
- package/docs/adr/0365-a-guild-engages-a-project-through-each-members-own-door.md +58 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +233 -6
- package/docs/api-reference.md +8 -5
- package/docs/copy-inventory.md +361 -315
- package/docs/copy-registry.json +868 -419
- package/docs/file-map.md +4 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +856 -771
- package/modules/platform-identity/application-echo.js +11 -5
- package/modules/platform-identity/guild-engagements.js +255 -0
- package/modules/platform-identity/guilds.js +3 -1
- package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
- package/modules/platform-identity/routes/guilds.js +35 -1
- package/modules/platform-identity/routes/my-projects.js +27 -3
- package/modules/platform-identity/routes/projects.js +44 -0
- package/modules/platform-identity/routes/sso.js +21 -3
- package/modules/public-landing/public/assets/cosmos.css +7 -0
- package/modules/public-landing/public/guild.html +633 -0
- package/modules/public-landing/public/guild.probes.json +63 -0
- package/modules/public-landing/public/guild.states.json +43 -0
- package/modules/public-landing/public/projects.html +252 -16
- package/modules/public-landing/public/projects.probes.json +26 -1
- package/modules/public-landing/public/projects.states.json +4 -0
- package/modules/ui-design/kit/fixtures/guilds.json +73 -0
- package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
- package/modules/ui-design/kit/serve.js +99 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +13 -0
- package/tests/applicant_profile_boundary.mjs +104 -1
- package/tests/application_echo.mjs +91 -5
- package/tests/guild_engagement.mjs +881 -0
- package/tests/guild_engagement_db.mjs +240 -0
- package/tests/hub_engagement_cards.mjs +307 -0
- package/tests/hub_guild_page.mjs +624 -0
- package/tests/privacy_matrix.mjs +216 -14
- package/tests/project_invite_ui.mjs +187 -3
- package/tests/render_check.mjs +2 -2
- package/tests/ui_design_kit.mjs +3 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"forge-crew": {
|
|
3
|
+
"public": {
|
|
4
|
+
"guild": {
|
|
5
|
+
"slug": "forge-crew",
|
|
6
|
+
"name": "Forge Crew",
|
|
7
|
+
"description": "Builders who like the unglamorous parts: migrations, test harnesses, the deploy path. We pick up the work nobody else claims.",
|
|
8
|
+
"created_at": "2026-09-12T10:20:00.000Z"
|
|
9
|
+
},
|
|
10
|
+
"members": [
|
|
11
|
+
{ "handle": "mara-quinn", "display_name": "Mara Quinn", "avatar_url": "/__kit/avatar.svg", "membership_kind": "owner" },
|
|
12
|
+
{ "handle": "ines-duarte", "display_name": "Ines Duarte", "avatar_url": null, "membership_kind": "member" },
|
|
13
|
+
{ "handle": "kip", "display_name": "Kip", "avatar_url": "/__kit/avatar.svg", "membership_kind": "member" },
|
|
14
|
+
{ "handle": "theo-park", "display_name": "Theo Park", "avatar_url": "/__kit/avatar.svg", "membership_kind": "member" }
|
|
15
|
+
],
|
|
16
|
+
"size": 4,
|
|
17
|
+
"totals": { "credits": 2000, "works_shipped": 100, "karma": 50, "projects": 0, "as_of": "2026-10-01T03:00:00.000Z" }
|
|
18
|
+
},
|
|
19
|
+
"members": {
|
|
20
|
+
"guild": {
|
|
21
|
+
"slug": "forge-crew",
|
|
22
|
+
"name": "Forge Crew",
|
|
23
|
+
"description": "Builders who like the unglamorous parts: migrations, test harnesses, the deploy path. We pick up the work nobody else claims.",
|
|
24
|
+
"visibility": "public"
|
|
25
|
+
},
|
|
26
|
+
"you": { "membership_kind": "member", "shown_publicly": false, "counted_in_totals": true },
|
|
27
|
+
"members": [
|
|
28
|
+
{ "handle": "mara-quinn", "display_name": "Mara Quinn", "avatar_url": "/__kit/avatar.svg", "membership_kind": "owner" },
|
|
29
|
+
{ "handle": "builder", "display_name": "A Builder", "avatar_url": "/__kit/avatar.svg", "membership_kind": "member" },
|
|
30
|
+
{ "handle": "ines-duarte", "display_name": "Ines Duarte", "avatar_url": null, "membership_kind": "member" },
|
|
31
|
+
{ "handle": "kip", "display_name": "Kip", "avatar_url": "/__kit/avatar.svg", "membership_kind": "member" },
|
|
32
|
+
{ "handle": "sol-reyes", "display_name": "Sol Reyes", "avatar_url": null, "membership_kind": "member" },
|
|
33
|
+
{ "handle": "theo-park", "display_name": "Theo Park", "avatar_url": "/__kit/avatar.svg", "membership_kind": "member" }
|
|
34
|
+
],
|
|
35
|
+
"count": 6
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"night-shift": {
|
|
39
|
+
"public": null,
|
|
40
|
+
"members": {
|
|
41
|
+
"guild": {
|
|
42
|
+
"slug": "night-shift",
|
|
43
|
+
"name": "Night Shift",
|
|
44
|
+
"description": "",
|
|
45
|
+
"visibility": "unlisted"
|
|
46
|
+
},
|
|
47
|
+
"you": { "membership_kind": "owner", "shown_publicly": false, "counted_in_totals": false },
|
|
48
|
+
"members": [
|
|
49
|
+
{ "handle": "builder", "display_name": "A Builder", "avatar_url": "/__kit/avatar.svg", "membership_kind": "owner" },
|
|
50
|
+
{ "handle": "ines-duarte", "display_name": "Ines Duarte", "avatar_url": null, "membership_kind": "member" },
|
|
51
|
+
{ "handle": "sol-reyes", "display_name": "Sol Reyes", "avatar_url": null, "membership_kind": "member" }
|
|
52
|
+
],
|
|
53
|
+
"count": 3
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"open-hands": {
|
|
57
|
+
"public": {
|
|
58
|
+
"guild": {
|
|
59
|
+
"slug": "open-hands",
|
|
60
|
+
"name": "Open Hands",
|
|
61
|
+
"description": "A newer guild for first-time builders. Ask anything.",
|
|
62
|
+
"created_at": "2026-10-03T16:05:00.000Z"
|
|
63
|
+
},
|
|
64
|
+
"members": [
|
|
65
|
+
{ "handle": "ada-ng", "display_name": "Ada Ng", "avatar_url": "/__kit/avatar.svg", "membership_kind": "owner" },
|
|
66
|
+
{ "handle": "ruth-okafor", "display_name": "Ruth Okafor", "avatar_url": null, "membership_kind": "member" }
|
|
67
|
+
],
|
|
68
|
+
"size": 2,
|
|
69
|
+
"totals": null
|
|
70
|
+
},
|
|
71
|
+
"members": null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -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
|
|
|
@@ -436,6 +439,85 @@ function handlePatch(res, body) {
|
|
|
436
439
|
return json(res, 200, { handle: h, previous_handle: previous || null });
|
|
437
440
|
}
|
|
438
441
|
|
|
442
|
+
// The hub's guild page (task 1002301): GET /guilds/:slug (public), GET /guilds/:slug/members
|
|
443
|
+
// and PATCH /guilds/:slug/membership (own-scope), answered from ONE set of guilds held for the
|
|
444
|
+
// run (guilds.json, or the file a states file's stub.fixtures names), keyed by slug, each with
|
|
445
|
+
// the public read's body (null = the 404 an unlisted or unknown guild answers) and the
|
|
446
|
+
// signed-in builder's members read (null = not a member: the same 404). The PATCH mirrors the
|
|
447
|
+
// route's schema — the two booleans, at least one — so a save the page re-reads comes back saved.
|
|
448
|
+
let GUILDS = null;
|
|
449
|
+
function guildsHeld() {
|
|
450
|
+
if (!GUILDS) {
|
|
451
|
+
const name = 'guilds';
|
|
452
|
+
GUILDS = JSON.parse(fs.readFileSync(path.join(FIXTURES, (ALIAS[name] || name) + '.json'), 'utf8'));
|
|
453
|
+
}
|
|
454
|
+
return GUILDS;
|
|
455
|
+
}
|
|
456
|
+
// a root-relative avatar is this stub's own (/__kit/avatar.svg), absolutised as me() does
|
|
457
|
+
function withAvatars(body, origin) {
|
|
458
|
+
if (!body || !Array.isArray(body.members)) return body;
|
|
459
|
+
return { ...body, members: body.members.map((m) => (typeof m.avatar_url === 'string' && m.avatar_url.startsWith('/') ? { ...m, avatar_url: origin + m.avatar_url } : m)) };
|
|
460
|
+
}
|
|
461
|
+
const PAGE_ALIAS = { g: 'guild' };
|
|
462
|
+
const GUILD_NOT_FOUND ={ error: { code: 'guild_not_found', message: 'guild_not_found' } };
|
|
463
|
+
function guildRead(res, slug, half, origin) {
|
|
464
|
+
const g = guildsHeld()[String(slug).toLowerCase()];
|
|
465
|
+
const body = g && g[half];
|
|
466
|
+
return body ? json(res, 200, withAvatars(body, origin)) : json(res, 404, GUILD_NOT_FOUND);
|
|
467
|
+
}
|
|
468
|
+
function guildMembershipPatch(res, slug, body) {
|
|
469
|
+
const keys = Object.keys(body);
|
|
470
|
+
const bad = keys.find((k) => !['shown_publicly', 'counted_in_totals'].includes(k));
|
|
471
|
+
if (bad) return refuse(res, 400, 'validation_failed', `unknown field: ${bad}`);
|
|
472
|
+
for (const k of keys) if (typeof body[k] !== 'boolean') return refuse(res, 400, 'validation_failed', `${k} must be a boolean`);
|
|
473
|
+
if (!keys.length) return refuse(res, 400, 'nothing_to_update', 'send shown_publicly and/or counted_in_totals');
|
|
474
|
+
const g = guildsHeld()[String(slug).toLowerCase()];
|
|
475
|
+
if (!g || !g.members) return json(res, 404, GUILD_NOT_FOUND);
|
|
476
|
+
Object.assign(g.members.you, body);
|
|
477
|
+
return json(res, 200, { shown_publicly: g.members.you.shown_publicly, counted_in_totals: g.members.you.counted_in_totals });
|
|
478
|
+
}
|
|
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
|
+
|
|
439
521
|
const server = http.createServer((req, res) => {
|
|
440
522
|
const u = new URL(req.url, 'http://x');
|
|
441
523
|
const p = u.pathname;
|
|
@@ -453,6 +535,11 @@ const server = http.createServer((req, res) => {
|
|
|
453
535
|
if (rest === '/me/cross-project' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => crossProjectPatch(res, body)) : json(res, 401, UNAUTH);
|
|
454
536
|
if (rest === '/me/profile' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => profilePatch(res, body)) : json(res, 401, UNAUTH);
|
|
455
537
|
if (rest === '/me/handle' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => handlePatch(res, body)) : json(res, 401, UNAUTH);
|
|
538
|
+
const guild = rest.match(/^\/guilds\/([^/]+)(\/members|\/membership|\/engagements)?$/);
|
|
539
|
+
if (guild && !guild[2] && req.method === 'GET') return guildRead(res, guild[1], 'public', origin);
|
|
540
|
+
if (guild && guild[2] === '/members' && req.method === 'GET') return AUTH ? guildRead(res, guild[1], 'members', origin) : json(res, 401, UNAUTH);
|
|
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);
|
|
456
543
|
if (rest === '/provisioning/instances' && req.method === 'GET') return AUTH ? instances(res, null, false) : json(res, 401, UNAUTH);
|
|
457
544
|
// the planet-physics questions' pre-create check (task 1004418): nothing stops here —
|
|
458
545
|
// the kit carries no screen. Of the soft-limit table it mirrors only the three hide
|
|
@@ -491,10 +578,13 @@ const server = http.createServer((req, res) => {
|
|
|
491
578
|
// decline receipts a real render or probe of the my-projects block drives,
|
|
492
579
|
// so those buttons are not stuck picturing only their failure branch.
|
|
493
580
|
if (rest === '/my-projects/join' && req.method === 'POST') {
|
|
494
|
-
return AUTH ? readBody(req, (body) =>
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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);
|
|
498
588
|
}
|
|
499
589
|
if (/^\/my-projects\/[^/]+\/leave$/.test(rest) && req.method === 'POST') return AUTH ? json(res, 200, { ok: true, left: true }) : json(res, 401, UNAUTH);
|
|
500
590
|
if (/^\/my-projects\/invites\/[^/]+\/decline$/.test(rest) && req.method === 'POST') return AUTH ? json(res, 200, { ok: true, accepted: false }) : json(res, 401, UNAUTH);
|
|
@@ -507,6 +597,7 @@ const server = http.createServer((req, res) => {
|
|
|
507
597
|
invite_url: 'https://mercury.example.test/builders/watch?invite=' + encodeURIComponent(body.github_login || ''),
|
|
508
598
|
})) : json(res, 401, UNAUTH);
|
|
509
599
|
}
|
|
600
|
+
if (rest === '/projects/invite-guild' && req.method === 'POST') return AUTH ? readBody(req, (body) => guildInvitePost(res, body)) : json(res, 401, UNAUTH);
|
|
510
601
|
// the manage flows (task 1003345): every door on a row and on the manage page only ASKS
|
|
511
602
|
// (ADR 0111) — the stub answers each ask with the route's receipt so the probes can drive
|
|
512
603
|
// attach / restart / teardown / revive / join end to end; nothing changes in the fixture
|
|
@@ -550,7 +641,10 @@ const server = http.createServer((req, res) => {
|
|
|
550
641
|
// TASK_DETAIL_PAGE_RE / IDEA_DETAIL_PAGE_RE routes). Without this the states
|
|
551
642
|
// for those pages could only be rendered at a URL production never serves, and
|
|
552
643
|
// a page script that reads its id out of location.pathname would see none.
|
|
553
|
-
|
|
644
|
+
// A namespace word may name its page differently: the hub serves /g/<slug> from
|
|
645
|
+
// guild.html (platform-server.js's /g/:slug route, task 1002301).
|
|
646
|
+
const base = under2 === '' ? 'index' : (fs.existsSync(path.join(DIR, under2 + '.html')) ? under2 : under2.replace(/\/[^/]+$/, ''));
|
|
647
|
+
const name = PAGE_ALIAS[base] && fs.existsSync(path.join(DIR, PAGE_ALIAS[base] + '.html')) ? PAGE_ALIAS[base] : base;
|
|
554
648
|
if (!name.includes('/') && fs.existsSync(path.join(DIR, name + '.html'))) {
|
|
555
649
|
try { res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-store' }); return res.end(page(name + '.html').html); }
|
|
556
650
|
catch (e) { res.writeHead(500, { 'content-type': 'text/plain' }); return res.end('render failed: ' + e.stack); }
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -9108,5 +9108,17 @@
|
|
|
9108
9108
|
"id": "1004581",
|
|
9109
9109
|
"text": "The street no longer shows the small grey line of words above the buildings. The owner wants the city to read at a glance from its look alone, and the line was also wrong in light mode. It is gone in light and dark and on phon"
|
|
9110
9110
|
}
|
|
9111
|
+
],
|
|
9112
|
+
"1.21.57": [
|
|
9113
|
+
{
|
|
9114
|
+
"id": "1002301",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
9111
9123
|
]
|
|
9112
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.
|
|
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');
|
package/src/platform-server.js
CHANGED
|
@@ -305,6 +305,19 @@ function buildPlatformApp() {
|
|
|
305
305
|
if (!html) return next();
|
|
306
306
|
res.type('html').send(html);
|
|
307
307
|
});
|
|
308
|
+
// THE GUILD PAGE (task 1002301, R25; ADR 0336 D4/D6): /g/<slug>, the namespace
|
|
309
|
+
// D6 reserved. Hub-only for the /account reason — every read it makes is a
|
|
310
|
+
// platform-identity route. ONE shell for every slug: the page reads the public
|
|
311
|
+
// guild and, signed in, the members' own-scope read, so whether a guild exists
|
|
312
|
+
// (and whether an unlisted one is yours) is answered by those routes and never
|
|
313
|
+
// by this one. A segment that cannot be a slug falls through to the 404 tail.
|
|
314
|
+
app.get('/g/:slug', (req, res, next) => {
|
|
315
|
+
if (!isApexHost(req) || !isModuleEnabled('platform-identity')) return next();
|
|
316
|
+
if (!/^[A-Za-z0-9-]{2,39}$/.test(req.params.slug)) return next();
|
|
317
|
+
const html = landingPage('guild.html');
|
|
318
|
+
if (!html) return next();
|
|
319
|
+
res.type('html').send(html);
|
|
320
|
+
});
|
|
308
321
|
// Assets fall through to the 404 tail when no module claims the apex root — the
|
|
309
322
|
// handler is not mounted at all rather than pointed at a missing directory.
|
|
310
323
|
const landingAssets = landingDir() && express.static(landingDir(), { index: false, fallthrough: true });
|
|
@@ -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)
|
|
244
|
-
|
|
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
|