@bongos/core 1.21.56 → 1.21.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.bongos-core.json +106 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +13 -2
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +2 -0
  8. package/docs/adr/0365-a-guild-engages-a-project-through-each-members-own-door.md +58 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +233 -6
  11. package/docs/api-reference.md +8 -5
  12. package/docs/copy-inventory.md +361 -315
  13. package/docs/copy-registry.json +868 -419
  14. package/docs/file-map.md +4 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +38 -4
  17. package/docs/page-readings.json +856 -771
  18. package/modules/platform-identity/application-echo.js +11 -5
  19. package/modules/platform-identity/guild-engagements.js +255 -0
  20. package/modules/platform-identity/guilds.js +3 -1
  21. package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
  22. package/modules/platform-identity/routes/guilds.js +35 -1
  23. package/modules/platform-identity/routes/my-projects.js +27 -3
  24. package/modules/platform-identity/routes/projects.js +44 -0
  25. package/modules/platform-identity/routes/sso.js +21 -3
  26. package/modules/public-landing/public/assets/cosmos.css +7 -0
  27. package/modules/public-landing/public/guild.html +633 -0
  28. package/modules/public-landing/public/guild.probes.json +63 -0
  29. package/modules/public-landing/public/guild.states.json +43 -0
  30. package/modules/public-landing/public/projects.html +252 -16
  31. package/modules/public-landing/public/projects.probes.json +26 -1
  32. package/modules/public-landing/public/projects.states.json +4 -0
  33. package/modules/ui-design/kit/fixtures/guilds.json +73 -0
  34. package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
  35. package/modules/ui-design/kit/serve.js +99 -5
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +12 -0
  39. package/scripts/gds/run-unit-tests.js +5 -0
  40. package/src/module-api.js +1 -1
  41. package/src/platform-server.js +13 -0
  42. package/tests/applicant_profile_boundary.mjs +104 -1
  43. package/tests/application_echo.mjs +91 -5
  44. package/tests/guild_engagement.mjs +881 -0
  45. package/tests/guild_engagement_db.mjs +240 -0
  46. package/tests/hub_engagement_cards.mjs +307 -0
  47. package/tests/hub_guild_page.mjs +624 -0
  48. package/tests/privacy_matrix.mjs +216 -14
  49. package/tests/project_invite_ui.mjs +187 -3
  50. package/tests/render_check.mjs +2 -2
  51. package/tests/ui_design_kit.mjs +3 -3
@@ -0,0 +1,624 @@
1
+ // tests/hub_guild_page.mjs — the hub's guild page at /g/<slug> (task 1002301, R25;
2
+ // ADR 0336 D4/D6, ADR 0337 D4.4).
3
+ //
4
+ // A guild spans projects, so its page is the hub's. One shell serves every slug;
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 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:
10
+ // 1. the page as a file: portable, dark only, noindex, no page :root, no
11
+ // innerHTML, no GitHub id or login read, and only the four guild routes;
12
+ // 2. its slug rule is the module's (a boundary-forced copy);
13
+ // 3. the bar is the hub's — the account page's chrome, with no current place
14
+ // and the sign-in doors pointed back at the guild by the script;
15
+ // 4. its script, EXECUTED in a node:vm under a DOM stub together with
16
+ // assets/hub-bar.js: the public view (the roster, the band floors, "under
17
+ // 10"), "no guild here" as ONE answer for an unknown slug, an unlisted guild
18
+ // and a malformed path, the members' view, the prompt after a guild goes
19
+ // public (asked, never assumed; "not now" remembered per guild), the
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);
23
+ // 5. the route: /g/<slug> on the hub's apex only, never on a child or the
24
+ // builders/status hosts, and a segment that cannot be a slug falls through.
25
+ // What the public roster may hold is the server's, and tests/privacy_matrix.mjs
26
+ // carries its cells; this file proves the page shows what it is given and asks
27
+ // for nothing more.
28
+ //
29
+ // DB-free. Run: node --test tests/hub_guild_page.mjs
30
+
31
+ import assert from 'node:assert/strict';
32
+ import { test, before, after } from 'node:test';
33
+ import http from 'node:http';
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import vm from 'node:vm';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { createRequire } from 'node:module';
39
+
40
+ const require = createRequire(import.meta.url);
41
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
42
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
43
+ const read = (f) => fs.readFileSync(path.join(LANDING, f), 'utf8').replace(/\r\n/g, '\n');
44
+ const PAGE = read('guild.html');
45
+ const SCRIPT = PAGE.split('<script>').pop().split('</script>')[0];
46
+ const BAR = read('assets/hub-bar.js');
47
+ const HUB = read('projects.html');
48
+ const ACCOUNT = read('account.html');
49
+
50
+ const guilds = require('../modules/platform-identity/guilds.js');
51
+ const { applyBrandTokens } = require('../src/bongos/serve-internal.js');
52
+
53
+ // ── 1. the page as a file ────────────────────────────────────────────────────
54
+
55
+ test('portable and dark only: stamped title, noindex, balanced sentinels, no :root, no literal colour, no baked apex', () => {
56
+ assert.match(PAGE, /^<html lang="en" data-mode="dark">/m, 'dark is fixed on <html> (ADR 0204)');
57
+ assert.match(PAGE, /<title>\{\{productName\}\} — Guild<\/title>/);
58
+ assert.match(PAGE, /<meta name="robots" content="noindex">/, 'one shell answers every slug, so a guild that does not exist must not index as a page');
59
+ const styles = [...PAGE.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
60
+ assert.doesNotMatch(styles, /:root\s*\{/, 'a page-local :root outranks the injected pack');
61
+ assert.doesNotMatch(styles, /#[0-9a-f]{3,8}\b/i, 'every colour is a token, never a literal');
62
+ assert.doesNotMatch(PAGE, /cloudbongos\.com/, 'no hardcoded apex — the file ships to every instance');
63
+ assert.doesNotMatch(PAGE, /gtag|plausible|googletagmanager/);
64
+ assert.equal((PAGE.match(/<!--apex-only:start-->/g) || []).length, (PAGE.match(/<!--apex-only:end-->/g) || []).length, 'balanced sentinels');
65
+ const stamped = applyBrandTokens(PAGE, { identity: { productName: 'Spike Harbor' } }, 'https://spike.example', 'https://builders.spike.example', '');
66
+ assert.doesNotMatch(stamped, /\{\{[a-zA-Z]+\}\}/, 'every token stamps');
67
+ assert.match(stamped, /<title>Spike Harbor — Guild<\/title>/);
68
+ assert.match(PAGE, /<link rel="stylesheet" href="\/assets\/cosmos\.css">/, 'the hub’s sheet, which carries [hidden]{display:none !important} over the page’s grid rules');
69
+ });
70
+
71
+ test('the script builds no markup from strings, never reads a GitHub id or login, and the regions ship hidden', () => {
72
+ assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML/, 'live DOM is textContent / createElement only');
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)');
74
+ for (const id of ['guildBody', 'missing', 'missingSignin', 'gYou', 'showAsk', 'shownUnlisted', 'gRosterEmpty', 'gApply']) {
75
+ assert.match(PAGE, new RegExp(`id="${id}"[^>]*\\bhidden\\b`), `#${id} ships hidden`);
76
+ }
77
+ for (const [name, values] of [['shown', ['on', 'off']], ['counted', ['on', 'off']]]) {
78
+ for (const v of values) assert.match(PAGE, new RegExp(`<input type="radio" name="${name}" value="${v}">`));
79
+ }
80
+ for (const id of ['askYes', 'askNo', 'youSave', 'gRoster', 'tShipped', 'tCredits', 'tKarma', 'tProjects']) assert.match(PAGE, new RegExp(`id="${id}"`));
81
+ });
82
+
83
+ test('the page calls only the four guild routes; the bar’s two calls stay in the shared asset', () => {
84
+ const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST|DELETE)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
85
+ assert.deepEqual([...new Set(calls)].sort(), ['GET /guilds/', 'PATCH /guilds/', 'POST /guilds/'].sort(), 'every call is built on /guilds/<slug>');
86
+ assert.deepEqual([...SCRIPT.matchAll(/request\('[A-Z]+', '\/guilds\/' \+ slug( \+ '[^']*')?/g)].map((m) => m[0]).sort(), [
87
+ "request('GET', '/guilds/' + slug",
88
+ "request('GET', '/guilds/' + slug",
89
+ "request('GET', '/guilds/' + slug + '/members'",
90
+ "request('GET', '/guilds/' + slug + '/members'",
91
+ "request('PATCH', '/guilds/' + slug + '/membership'",
92
+ "request('POST', '/guilds/' + slug + '/engagements'",
93
+ ].sort());
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');
95
+ assert.doesNotMatch(SCRIPT, /function (openBand|closeMenus|openMenu)\b|\/auth\/logout/, 'no second copy of the bands or sign out in the page');
96
+ assert.match(SCRIPT, /var bar = window\.HubBar \|\| \{ paint: function \(\) \{\}, ready: Promise\.resolve\(null\) \};/);
97
+ });
98
+
99
+ // ── 2. the slug rule is the module's ─────────────────────────────────────────
100
+
101
+ test('the page’s slug rule is guilds.js’s own (normalizeHandle’s charset, a two-character floor)', () => {
102
+ const fn = SCRIPT.match(/ function slugFromPath\(pathname\) \{[\s\S]*?\n \}\n/);
103
+ const re = SCRIPT.match(/ var SLUG_RE = (\/[^\n]+\/);\n/);
104
+ assert.ok(fn && re, 'slugFromPath and SLUG_RE are still whole declarations');
105
+ const slugFromPath = vm.runInNewContext(`var SLUG_RE = ${re[1]};\n${fn[0]}; slugFromPath`);
106
+ for (const s of ['forge-crew', 'ab', 'a--b', 'x1', '9lives', 'a'.repeat(39), 'a', 'a'.repeat(40), '-ab', 'ab-', 'a_b', 'a.b', 'café', 'me', 'api', 'Forge-Crew']) {
107
+ const lower = s.toLowerCase();
108
+ const moduleSays = guilds.lookupSlug(s) === lower && guilds.normalizeGuildSlug(s).error !== 'slug_invalid';
109
+ assert.equal(slugFromPath(`/g/${encodeURIComponent(s)}`) === lower, moduleSays, `"${s}": the page and the module disagree`);
110
+ }
111
+ assert.equal(slugFromPath('/g/forge-crew/'), 'forge-crew', 'a trailing slash is the same guild');
112
+ assert.equal(slugFromPath('/g/forge-crew/members'), null, 'one segment only');
113
+ assert.equal(slugFromPath('/g/%E0%A4%A'), null, 'a malformed escape is no guild, not a thrown page');
114
+ });
115
+
116
+ // ── 3. the bar is the hub's ──────────────────────────────────────────────────
117
+
118
+ const chrome = (html) => {
119
+ const m = html.match(/<a class="skip vh"[\s\S]*?<div class="menuBand" id="menuProfile"[\s\S]*?\n<\/div>\n/);
120
+ assert.ok(m, 'the skip link, the bar and both bands are one block');
121
+ return m[0];
122
+ };
123
+
124
+ test('the bar is the account page’s: the same chrome, minus the current place, with the sign-in doors left for the script to point', () => {
125
+ for (const piece of [
126
+ '<header class="shell bar">',
127
+ '<a class="barMark" id="barMark" href="/">',
128
+ '<button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>',
129
+ '<button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden>',
130
+ '<div class="menuBand" id="menuProjects" data-menu="projects" hidden>',
131
+ '<div class="menuBand" id="menuProfile" data-menu="profile" hidden>',
132
+ '<li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>',
133
+ ]) {
134
+ assert.ok(HUB.includes(piece), `projects.html no longer carries: ${piece}`);
135
+ assert.ok(PAGE.includes(piece), `guild.html does not carry the hub's: ${piece}`);
136
+ }
137
+ const norm = (html) => chrome(html)
138
+ .replace(/start\?return=\/account&amp;repos=1/, 'start?repos=1')
139
+ .replace(/href="#\w+">Skip to content/, 'href="#MAIN">Skip to content')
140
+ .replace(/<!--[\s\S]*?-->/g, '')
141
+ .replace(/^[ \t]*\n/gm, '')
142
+ .replace(' aria-current="page"', '');
143
+ assert.equal(norm(PAGE), norm(ACCOUNT), 'one bar with /account, so a change to it is a change to both');
144
+ assert.doesNotMatch(chrome(PAGE), /aria-current/, 'no band item is this page');
145
+ assert.match(PAGE, /<a class="signinDoor" id="signinTop" hidden href="\/api\/bongos\/auth\/web\/start\?repos=1">Sign in<\/a>/,
146
+ 'no return in the markup — one shell serves every slug; without script the route falls back to the Referer, which is this page');
147
+ assert.match(PAGE, /<a class="skip vh" href="#guild">/);
148
+ assert.match(PAGE, /<main class="gld" id="guild">/, 'the skip link has its landmark');
149
+ const styles = [...PAGE.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
150
+ assert.doesNotMatch(styles, /\.(shell|bar|barMark|barLeft|barRight|trig|who|signinDoor|menuBand|band[A-Z]\w*)\b[^{}]*\{/, 'no bar or band rule in the page');
151
+ });
152
+
153
+ // ── 4. the script, executed ──────────────────────────────────────────────────
154
+
155
+ const PUBLIC = () => ({
156
+ guild: { slug: 'forge-crew', name: 'Forge Crew', description: 'We ship the boring parts.', created_at: '2026-09-12T10:20:00.000Z' },
157
+ members: [
158
+ { handle: 'mara', display_name: 'Mara Quinn', avatar_url: 'https://avatars.example/u/1', membership_kind: 'owner' },
159
+ { handle: 'ines', display_name: 'Ines Duarte', avatar_url: null, membership_kind: 'member' },
160
+ { handle: 'kip', display_name: null, avatar_url: 'javascript:alert(1)', membership_kind: 'member' },
161
+ ],
162
+ size: 3,
163
+ totals: { credits: 2000, works_shipped: 100, karma: 0, projects: 10, as_of: '2026-10-01T03:00:00.000Z' },
164
+ });
165
+ const MEMBERS = (you = {}, visibility = 'public') => ({
166
+ guild: { slug: 'forge-crew', name: 'Forge Crew', description: 'We ship the boring parts.', visibility },
167
+ you: { membership_kind: 'member', shown_publicly: false, counted_in_totals: true, ...you },
168
+ members: [...PUBLIC().members, { handle: 'octo', display_name: 'Octo', avatar_url: null, membership_kind: 'member' }, { handle: 'sol', display_name: 'Sol', avatar_url: null, membership_kind: 'member' }],
169
+ count: 5,
170
+ });
171
+ const NOT_FOUND = { status: 404, data: { error: { code: 'guild_not_found' } } };
172
+
173
+ // A DOM stub just wide enough for this page and hub-bar.js: every id in the
174
+ // markup is an element (hidden where the markup says so); created elements keep
175
+ // their children, and setting textContent clears them, as in a browser.
176
+ function node(tag, attrs = {}) {
177
+ const n = { tag, attrs, children: [], _text: '', className: '', disabled: false, hidden: false, style: {}, _on: {} };
178
+ Object.defineProperty(n, 'textContent', {
179
+ get() { return n._text + n.children.map((c) => c.textContent).join(''); },
180
+ set(v) { n._text = String(v); n.children = []; },
181
+ });
182
+ n.appendChild = (c) => { n.children.push(c); return c; };
183
+ n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
184
+ n.getAttribute = (k) => (k in n.attrs ? n.attrs[k] : null);
185
+ n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
186
+ n.closest = () => null;
187
+ n.contains = () => false;
188
+ n.focus = () => {};
189
+ return n;
190
+ }
191
+ function boot({ pathname = '/g/forge-crew', me = null, answer = () => NOT_FOUND, storage = {}, storageThrows = false } = {}) {
192
+ const els = new Map();
193
+ for (const m of PAGE.matchAll(/<(\w+)([^>]*?)\sid="([\w-]+)"([^>]*)>/g)) {
194
+ const attrs = Object.fromEntries([...(m[2] + m[4]).matchAll(/\s([\w-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
195
+ const e = node(m[1], attrs);
196
+ e.id = m[3];
197
+ e.hidden = /\shidden\b/.test(m[2] + m[4]);
198
+ els.set(m[3], e);
199
+ }
200
+ const radios = [...PAGE.matchAll(/<input type="radio" name="(\w+)" value="(\w+)">/g)].map((m) => Object.assign(node('input'), { name: m[1], value: m[2], checked: false }));
201
+ const trigs = [...els.values()].filter((e) => /\btrig\b/.test(e.attrs.class || ''));
202
+ const bands = ['menuProjects', 'menuProfile'].map((id) => els.get(id));
203
+ const calls = [];
204
+ const document = {
205
+ title: 'Spike Harbor — Guild', cookie: '', activeElement: null,
206
+ addEventListener() {},
207
+ getElementById: (id) => els.get(id) || null,
208
+ createElement: (tag) => node(tag),
209
+ querySelector(sel) {
210
+ if (sel === '.menuBand:not([hidden])') return bands.find((b) => !b.hidden) || null;
211
+ if (sel === '.trig[aria-expanded="true"]') return null;
212
+ const m = sel.match(/^input\[name="(\w+)"\]:checked$/);
213
+ if (!m) throw new Error(`unstubbed selector: ${sel}`);
214
+ return radios.find((r) => r.name === m[1] && r.checked) || null;
215
+ },
216
+ querySelectorAll(sel) {
217
+ if (sel === '.trig') return trigs;
218
+ if (sel === '.menuBand') return bands;
219
+ const m = sel.match(/^input\[name="(\w+)"\]$/);
220
+ if (!m) throw new Error(`unstubbed selector: ${sel}`);
221
+ return radios.filter((r) => r.name === m[1]);
222
+ },
223
+ };
224
+ const fetch = (url, init = {}) => {
225
+ const method = init.method || 'GET';
226
+ const body = init.body ? JSON.parse(init.body) : undefined;
227
+ const p = url.replace(/^\/api\/bongos/, '');
228
+ calls.push({ method, path: p, body });
229
+ const out = p === '/me' ? (me ? { status: 200, data: { builder: me } } : { status: 401, data: null }) : answer(method, p, body);
230
+ return Promise.resolve({ status: out.status, ok: out.status >= 200 && out.status < 300, json: () => Promise.resolve(out.data) });
231
+ };
232
+ const localStorage = {
233
+ getItem(k) { if (storageThrows) throw new Error('denied'); return k in storage ? storage[k] : null; },
234
+ setItem(k, v) { if (storageThrows) throw new Error('denied'); storage[k] = String(v); },
235
+ };
236
+ const window = { localStorage, location: { assign() {} } };
237
+ const ctx = vm.createContext({ document, fetch, location: { hostname: 'cloudbongos.com', pathname }, window });
238
+ vm.runInContext(BAR, ctx);
239
+ vm.runInContext(SCRIPT, ctx);
240
+ const dom = {
241
+ els, radios, calls, storage, document,
242
+ el: (id) => els.get(id),
243
+ text: (id) => els.get(id).textContent,
244
+ checked: (name) => (radios.find((r) => r.name === name && r.checked) || {}).value,
245
+ pick(name, value) {
246
+ for (const r of radios.filter((x) => x.name === name)) r.checked = r.value === value;
247
+ for (const f of radios.find((x) => x.name === name && x.value === value)._on.change || []) f({});
248
+ },
249
+ click(id) { for (const f of els.get(id)._on.click || []) f({ preventDefault() {} }); },
250
+ submit() { for (const f of els.get('youForm')._on.submit || []) f({ preventDefault() {} }); },
251
+ rows: () => els.get('gRoster').children,
252
+ guildCalls: () => calls.filter((c) => c.path.startsWith('/guilds/')),
253
+ };
254
+ return dom;
255
+ }
256
+ const settle = async () => { for (let i = 0; i < 24; i++) await new Promise((r) => setImmediate(r)); };
257
+ const ME = { github_login: 'octo', avatar_url: null };
258
+
259
+ test('signed out, a public guild: the identity, the band floors, the public roster — and one call, never the members’ read', async () => {
260
+ const dom = boot({ answer: (m, p) => (p === '/guilds/forge-crew' ? { status: 200, data: PUBLIC() } : NOT_FOUND) });
261
+ await settle();
262
+ assert.equal(dom.el('guildBody').hidden, false);
263
+ assert.equal(dom.el('loading').hidden, true);
264
+ assert.equal(dom.el('missing').hidden, true);
265
+ assert.equal(dom.el('gYou').hidden, true, 'nothing of yours on a page you are not signed in to');
266
+ assert.equal(dom.text('gName'), 'Forge Crew');
267
+ assert.equal(dom.document.title, 'Forge Crew — Spike Harbor');
268
+ assert.equal(dom.text('gKicker'), 'Guild', 'the public sees only public guilds, so it is not told which kind');
269
+ assert.equal(dom.text('gMeta'), '3 members · since Sep 2026', 'size is the public roster’s length (ADR 0336 D4)');
270
+ assert.equal(dom.text('gDesc'), 'We ship the boring parts.');
271
+ assert.deepEqual(['tShipped', 'tCredits', 'tKarma', 'tProjects'].map((id) => dom.text(id)), ['100+', '2,000+', 'under 10', '10+'],
272
+ 'band floors as published, never re-derived; 0 is below the first band');
273
+ assert.equal(dom.el('tKarma').className, 'under');
274
+ assert.equal(dom.text('gRecordNote'), 'Counted on 1 Oct 2026. Projects are the public projects its listed members build on.');
275
+ assert.deepEqual(dom.guildCalls().map((c) => `${c.method} ${c.path}`), ['GET /guilds/forge-crew'], 'a stranger’s page makes one guild call');
276
+ assert.equal(dom.el('signinTop').getAttribute('href'), '/api/bongos/auth/web/start?return=/g/forge-crew&repos=1', 'sign-in returns to this guild');
277
+ });
278
+
279
+ test('the roster: name, handle, the owner marked; a missing name falls back to the handle; only an http(s) avatar is drawn', async () => {
280
+ const dom = boot({ answer: (m, p) => (p === '/guilds/forge-crew' ? { status: 200, data: PUBLIC() } : NOT_FOUND) });
281
+ await settle();
282
+ const rows = dom.rows();
283
+ assert.equal(rows.length, 3);
284
+ const [mara, ines, kip] = rows.map((li) => li.children);
285
+ assert.equal(mara[0].tag, 'img');
286
+ assert.equal(mara[0].src, 'https://avatars.example/u/1');
287
+ assert.equal(mara[0].alt, '', 'decorative: the name is beside it');
288
+ assert.equal(mara[1].textContent, 'Mara Quinnmara');
289
+ assert.equal(mara[2].textContent, 'Owner');
290
+ assert.equal(ines[0].tag, 'span', 'no avatar → the initial, not a broken image');
291
+ assert.equal(ines[0].textContent, 'I');
292
+ assert.equal(ines[2].textContent, '', 'a member carries no tag');
293
+ assert.equal(kip[0].tag, 'span', 'a javascript: avatar is never an img src');
294
+ assert.equal(kip[1].children[0].textContent, 'kip', 'no display name → the handle');
295
+ assert.equal(dom.el('gRosterEmpty').hidden, true);
296
+ });
297
+
298
+ test('not counted yet, and an empty roster, say so', async () => {
299
+ const data = { ...PUBLIC(), members: [], size: 0, totals: null };
300
+ const dom = boot({ answer: () => ({ status: 200, data }) });
301
+ await settle();
302
+ assert.deepEqual(['tShipped', 'tCredits', 'tKarma', 'tProjects'].map((id) => dom.text(id)), ['—', '—', '—', '—']);
303
+ assert.equal(dom.text('gRecordNote'), 'Not counted yet. A guild’s record is counted once a week.');
304
+ assert.equal(dom.el('gRosterEmpty').hidden, false);
305
+ assert.equal(dom.text('gMeta'), '0 members · since Sep 2026');
306
+ });
307
+
308
+ test('"no guild here" is ONE answer: an unknown slug, an unlisted guild from outside, a malformed path — signed out it offers sign-in', async () => {
309
+ const out = boot({ pathname: '/g/night-shift' });
310
+ await settle();
311
+ assert.equal(out.el('missing').hidden, false);
312
+ assert.equal(out.el('guildBody').hidden, true);
313
+ assert.equal(out.el('missingSignin').hidden, false);
314
+ assert.equal(out.el('missingSignin').getAttribute('href'), '/api/bongos/auth/web/start?return=/g/night-shift&repos=1');
315
+ assert.equal(out.text('missingWhy'), 'There is no public guild at this address. If it is an unlisted guild you belong to, sign in to see it.');
316
+
317
+ const stranger = boot({ pathname: '/g/night-shift', me: ME });
318
+ await settle();
319
+ assert.deepEqual(stranger.guildCalls().map((c) => `${c.method} ${c.path}`), ['GET /guilds/night-shift', 'GET /guilds/night-shift/members']);
320
+ assert.equal(stranger.el('missing').hidden, false);
321
+ assert.equal(stranger.el('missingSignin').hidden, true, 'signed in already');
322
+ assert.equal(stranger.text('missingWhy'), 'There is no guild at this address that you can see. A guild that is unlisted is shown only to its members.');
323
+
324
+ const unknown = boot({ pathname: '/g/no-such-guild', me: ME });
325
+ await settle();
326
+ assert.equal(unknown.text('missingWhy'), stranger.text('missingWhy'), 'an unknown slug and someone else’s unlisted guild read the same');
327
+
328
+ for (const bad of ['/g/-x-', '/g/a', '/g/a_b', '/g/forge-crew/members']) {
329
+ const dom = boot({ pathname: bad, me: ME });
330
+ await settle();
331
+ assert.equal(dom.el('missing').hidden, false, `${bad} is no guild`);
332
+ assert.deepEqual(dom.guildCalls(), [], `${bad}: no guild read for a path that cannot be a slug`);
333
+ }
334
+ });
335
+
336
+ test('a read that fails is not "no guild here": the alert says to try again', async () => {
337
+ const dom = boot({ answer: () => ({ status: 500, data: null }) });
338
+ await settle();
339
+ assert.equal(dom.el('missing').hidden, true);
340
+ assert.equal(dom.el('pageAlert').className, 'alert on');
341
+ assert.equal(dom.text('pageAlert'), 'Couldn’t read this guild right now — refresh in a moment.');
342
+ });
343
+
344
+ test('signed in, not a member: the public page and nothing of yours', async () => {
345
+ const dom = boot({ me: ME, answer: (m, p) => (p === '/guilds/forge-crew' ? { status: 200, data: PUBLIC() } : NOT_FOUND) });
346
+ await settle();
347
+ assert.equal(dom.el('guildBody').hidden, false);
348
+ assert.equal(dom.el('gYou').hidden, true);
349
+ assert.equal(dom.rows().length, 3);
350
+ assert.equal(dom.text('gMembersSide'), 'The members who chose to be listed on this page.');
351
+ });
352
+
353
+ function memberAnswer(state) {
354
+ return (m, p, body) => {
355
+ if (p === '/guilds/forge-crew' && m === 'GET') return { status: 200, data: PUBLIC() };
356
+ if (p === '/guilds/forge-crew/members' && m === 'GET') return { status: 200, data: MEMBERS(state.you, state.visibility) };
357
+ if (p === '/guilds/forge-crew/membership' && m === 'PATCH') {
358
+ if (state.patch) return state.patch(body);
359
+ state.you = { ...state.you, ...body };
360
+ const you = MEMBERS(state.you).you;
361
+ return { status: 200, data: { shown_publicly: you.shown_publicly, counted_in_totals: you.counted_in_totals } };
362
+ }
363
+ return NOT_FOUND;
364
+ };
365
+ }
366
+
367
+ test('a member of a public guild who is not on its page: the members’ roster, and the prompt stands in for the pair', async () => {
368
+ const dom = boot({ me: ME, answer: memberAnswer({}) });
369
+ await settle();
370
+ assert.equal(dom.el('gYou').hidden, false);
371
+ assert.equal(dom.text('gKicker'), 'Guild · public');
372
+ assert.equal(dom.text('gMeta'), '5 members · 3 on the public page · since Sep 2026', 'both lists the member can see, each its own length');
373
+ assert.equal(dom.rows().length, 5, 'the members’ roster: everyone, private members included (joining is consent to be seen by the guild)');
374
+ assert.equal(dom.text('gYouSide'), 'You’re a member of this guild. Only you see this, and only you can change it.');
375
+ assert.equal(dom.el('showAsk').hidden, false, 'asked, never assumed (ADR 0336 D4 §3)');
376
+ assert.equal(dom.el('shownBand').hidden, true, 'while the prompt stands it is the control');
377
+ assert.equal(dom.checked('shown'), 'off');
378
+ assert.equal(dom.checked('counted'), 'on');
379
+ assert.equal(dom.text('countWhat'), 'Your numbers count toward this guild’s rounded totals — even while your record is hidden or your account is private. They are never shown on their own: only inside totals rounded down to a band (10+, 20+, 50+ …) and counted once a week. Leave them out of any guild you’d rather not share them with.',
380
+ 'the ADR 0337 D4.4 notice, said before the switch is touched');
381
+ });
382
+
383
+ test('the prompt: "show me" sends shown_publicly alone, then re-reads both; the pair takes its place', async () => {
384
+ const state = {};
385
+ const dom = boot({ me: ME, answer: memberAnswer(state) });
386
+ await settle();
387
+ dom.click('askYes');
388
+ await settle();
389
+ const patches = dom.calls.filter((c) => c.method === 'PATCH');
390
+ assert.deepEqual(patches, [{ method: 'PATCH', path: '/guilds/forge-crew/membership', body: { shown_publicly: true } }]);
391
+ const after = dom.guildCalls().slice(dom.guildCalls().indexOf(patches[0]) + 1).map((c) => `${c.method} ${c.path}`);
392
+ assert.deepEqual(after.sort(), ['GET /guilds/forge-crew', 'GET /guilds/forge-crew/members'], 'the public roster and size move with the switch, so both are re-read');
393
+ assert.equal(dom.text('youMsg'), 'Saved — you’re listed on its public page.');
394
+ assert.equal(dom.el('youMsg').className, 'projMsg ok');
395
+ assert.equal(dom.el('showAsk').hidden, true);
396
+ assert.equal(dom.el('shownBand').hidden, false);
397
+ assert.equal(dom.checked('shown'), 'on');
398
+ });
399
+
400
+ test('the prompt: "not now" writes nothing, keeps you off, and is remembered for this guild only', async () => {
401
+ const storage = {};
402
+ const dom = boot({ me: ME, answer: memberAnswer({}), storage });
403
+ await settle();
404
+ dom.click('askNo');
405
+ assert.equal(dom.calls.filter((c) => c.method === 'PATCH').length, 0, 'staying off needs no write');
406
+ assert.equal(dom.el('showAsk').hidden, true);
407
+ assert.equal(dom.el('shownBand').hidden, false);
408
+ assert.equal(dom.text('youMsg'), 'You’re staying off its public page. You can change that here at any time.');
409
+ assert.deepEqual(storage, { 'cb-guild-ask-forge-crew': 'no' });
410
+
411
+ const again = boot({ me: ME, answer: memberAnswer({}), storage });
412
+ await settle();
413
+ assert.equal(again.el('showAsk').hidden, true, 'not asked twice on this browser');
414
+ assert.equal(again.el('shownBand').hidden, false);
415
+
416
+ const blocked = boot({ me: ME, answer: memberAnswer({}), storageThrows: true });
417
+ await settle();
418
+ assert.equal(blocked.el('showAsk').hidden, false, 'storage that throws only means being asked again');
419
+ blocked.click('askNo');
420
+ assert.equal(blocked.el('showAsk').hidden, true);
421
+ });
422
+
423
+ test('the switches: nothing changed sends nothing; each save sends only what moved and says it plainly', async () => {
424
+ const state = {};
425
+ const storage = { 'cb-guild-ask-forge-crew': 'no' };
426
+ const dom = boot({ me: ME, answer: memberAnswer(state), storage });
427
+ await settle();
428
+ dom.submit();
429
+ assert.equal(dom.text('youMsg'), 'Nothing has changed.');
430
+ assert.equal(dom.calls.filter((c) => c.method === 'PATCH').length, 0);
431
+
432
+ dom.pick('counted', 'off');
433
+ assert.equal(dom.text('countWhat'), 'Your numbers are left out of this guild’s totals, from its next weekly count.', 'the notice follows the choice before it is saved');
434
+ dom.submit();
435
+ await settle();
436
+ assert.deepEqual(dom.calls.filter((c) => c.method === 'PATCH').at(-1).body, { counted_in_totals: false });
437
+ assert.equal(dom.text('youMsg'), 'Saved — your numbers are left out of its totals.');
438
+
439
+ dom.pick('shown', 'on');
440
+ dom.pick('counted', 'on');
441
+ dom.submit();
442
+ await settle();
443
+ assert.deepEqual(dom.calls.filter((c) => c.method === 'PATCH').at(-1).body, { shown_publicly: true, counted_in_totals: true });
444
+ assert.equal(dom.text('youMsg'), 'Saved — you’re listed on its public page, and your numbers count toward its totals.');
445
+ });
446
+
447
+ test('a refused or expired save is said, and the buttons come back', async () => {
448
+ const state = { patch: () => ({ status: 400, data: { error: { code: 'validation_failed', message: 'counted_in_totals must be a boolean' } } }) };
449
+ const dom = boot({ me: ME, answer: memberAnswer(state), storage: { 'cb-guild-ask-forge-crew': 'no' } });
450
+ await settle();
451
+ dom.pick('counted', 'off');
452
+ dom.submit();
453
+ await settle();
454
+ assert.equal(dom.text('youMsg'), 'Counted_in_totals must be a boolean.');
455
+ assert.equal(dom.el('youMsg').className, 'projMsg err');
456
+ assert.equal(dom.el('youSave').disabled, false);
457
+ state.patch = () => ({ status: 401, data: null });
458
+ dom.submit();
459
+ await settle();
460
+ assert.equal(dom.text('youMsg'), 'Your session expired — sign in again to save.');
461
+ assert.equal(dom.el('who').hidden, true, 'the chip is repainted signed out');
462
+ });
463
+
464
+ test('an unlisted guild, to its members: no public page, so no pair, no prompt, no shown_publicly sent, and no published record', async () => {
465
+ const answer = (owner) => (m, p, body) => {
466
+ if (p === '/guilds/night-shift' && m === 'GET') return NOT_FOUND;
467
+ if (p === '/guilds/night-shift/members') return { status: 200, data: { ...MEMBERS({ membership_kind: owner ? 'owner' : 'member' }, 'unlisted'), guild: { slug: 'night-shift', name: 'Night Shift', description: '', visibility: 'unlisted' } } };
468
+ if (m === 'PATCH') return { status: 200, data: { shown_publicly: false, counted_in_totals: body.counted_in_totals } };
469
+ return NOT_FOUND;
470
+ };
471
+ const dom = boot({ pathname: '/g/night-shift', me: ME, answer: answer(false) });
472
+ await settle();
473
+ assert.equal(dom.el('guildBody').hidden, false, 'a member reaches their unlisted guild through the own-scope read');
474
+ assert.equal(dom.text('gKicker'), 'Guild · unlisted');
475
+ assert.equal(dom.text('gMeta'), '5 members', 'no public page to count, and no founding date in the members’ read');
476
+ assert.equal(dom.el('gDesc').hidden, true);
477
+ assert.equal(dom.el('showAsk').hidden, true);
478
+ assert.equal(dom.el('shownBand').hidden, true);
479
+ assert.equal(dom.text('shownUnlisted'), 'This guild is unlisted, so it has no public page. If it is made public, you’ll be asked here before you’re listed on it.');
480
+ assert.equal(dom.el('gFigs').hidden, true);
481
+ assert.equal(dom.text('gRecordNote'), 'This guild is unlisted, so its record isn’t published anywhere.');
482
+ dom.pick('shown', 'on');
483
+ dom.pick('counted', 'off');
484
+ dom.submit();
485
+ await settle();
486
+ assert.deepEqual(dom.calls.filter((c) => c.method === 'PATCH').map((c) => c.body), [{ counted_in_totals: false }],
487
+ 'consent to a page that does not exist is never sent (ADR 0336 D4 §3: consent is to what the card showed)');
488
+
489
+ const owner = boot({ pathname: '/g/night-shift', me: ME, answer: answer(true) });
490
+ await settle();
491
+ assert.equal(owner.text('gYouSide'), 'You own this guild. Only you see this, and only you can change it.');
492
+ assert.equal(owner.text('shownUnlisted'), 'This guild is unlisted, so it has no public page. If you make it public, you’ll be listed on it as its owner, and each member will be asked before they are.',
493
+ 'a visibility change flips only the owner’s own flag');
494
+ });
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
+
578
+ // ── 5. the route ─────────────────────────────────────────────────────────────
579
+
580
+ const { buildPlatformApp } = require('../src/platform-server.js');
581
+ const modules = require('../src/modules.js');
582
+ let server;
583
+ let port;
584
+ before(async () => {
585
+ server = http.createServer(buildPlatformApp());
586
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
587
+ port = server.address().port;
588
+ });
589
+ after(async () => {
590
+ modules.setModuleOverrides({});
591
+ await new Promise((resolve) => server.close(resolve));
592
+ });
593
+ function get(pathname, host) {
594
+ return new Promise((resolve, reject) => {
595
+ http.get({ host: '127.0.0.1', port, path: pathname, headers: { Host: host } }, (res) => {
596
+ let body = '';
597
+ res.on('data', (c) => (body += c));
598
+ res.on('end', () => resolve({ status: res.statusCode, body }));
599
+ }).on('error', reject);
600
+ });
601
+ }
602
+
603
+ test('GET /g/<slug>: served on the hub’s apex, stamped; never on a child, the builders host or the status host', async () => {
604
+ modules.setModuleOverrides({ 'platform-identity': false });
605
+ const child = await get('/g/forge-crew', 'game.test.local');
606
+ assert.equal(child.status, 404, 'a child instance has no guilds, so no page');
607
+ modules.setModuleOverrides({ 'platform-identity': true });
608
+ for (const p of ['/g/forge-crew', '/g/Forge-Crew', '/g/forge-crew/', '/g/no-such-guild']) {
609
+ const hub = await get(p, 'game.test.local');
610
+ assert.equal(hub.status, 200, `${p}: one shell for every well-formed slug — the guild routes decide what exists`);
611
+ assert.match(hub.body, /— Guild<\/title>/);
612
+ assert.match(hub.body, /window\.__BRANDING__=/);
613
+ assert.doesNotMatch(hub.body, /\{\{[a-zA-Z]+\}\}/);
614
+ assert.match(hub.body, /<script src="\/assets\/hub-bar\.js\?v=[0-9a-f]{8}"><\/script>/, 'the shared bar script is content-stamped');
615
+ }
616
+ for (const p of ['/g/a', '/g/a_b', '/g/forge-crew/members', '/g/' + 'a'.repeat(40)]) {
617
+ const bad = await get(p, 'game.test.local');
618
+ assert.equal(bad.status, 404, `${p} cannot be a slug, so it is the 404 tail`);
619
+ }
620
+ for (const host of ['builders.test.local', 'status.test.local']) {
621
+ const other = await get('/g/forge-crew', host);
622
+ assert.doesNotMatch(other.body, /— Guild<\/title>/, `${host} must not serve the guild page`);
623
+ }
624
+ });