@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.
- package/.bongos-core.json +82 -47
- 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 +360 -352
- package/docs/copy-registry.json +479 -397
- package/docs/file-map.md +3 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +7 -1
- package/docs/page-readings.json +828 -811
- 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 +64 -2
- package/modules/public-landing/public/guild.probes.json +13 -3
- package/modules/public-landing/public/guild.states.json +10 -5
- 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/me__guild-engagements.json +7 -0
- package/modules/ui-design/kit/serve.js +54 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- 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 +94 -7
- package/tests/privacy_matrix.mjs +99 -9
- package/tests/project_invite_ui.mjs +187 -3
package/tests/hub_guild_page.mjs
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
//
|
|
4
4
|
// A guild spans projects, so its page is the hub's. One shell serves every slug;
|
|
5
5
|
// the page asks the public read (GET /guilds/:slug) and, for someone signed in,
|
|
6
|
-
// the members' own-scope read (GET /guilds/:slug/members), and writes
|
|
7
|
-
// the member's own two switches (PATCH /guilds/:slug/membership)
|
|
6
|
+
// the members' own-scope read (GET /guilds/:slug/members), and writes two things:
|
|
7
|
+
// the member's own two switches (PATCH /guilds/:slug/membership) and, for the
|
|
8
|
+
// owner only, the guild applying to a project (POST /guilds/:slug/engagements,
|
|
9
|
+
// task 1002303, R27). These pin:
|
|
8
10
|
// 1. the page as a file: portable, dark only, noindex, no page :root, no
|
|
9
|
-
// innerHTML, no GitHub id or login read, and only the
|
|
11
|
+
// innerHTML, no GitHub id or login read, and only the four guild routes;
|
|
10
12
|
// 2. its slug rule is the module's (a boundary-forced copy);
|
|
11
13
|
// 3. the bar is the hub's — the account page's chrome, with no current place
|
|
12
14
|
// and the sign-in doors pointed back at the guild by the script;
|
|
@@ -15,7 +17,9 @@
|
|
|
15
17
|
// 10"), "no guild here" as ONE answer for an unknown slug, an unlisted guild
|
|
16
18
|
// and a malformed path, the members' view, the prompt after a guild goes
|
|
17
19
|
// public (asked, never assumed; "not now" remembered per guild), the
|
|
18
|
-
// counted switch and its notice,
|
|
20
|
+
// counted switch and its notice, each save sending only what moved, and
|
|
21
|
+
// the owner's "apply to a project" (shown to the owner alone, one honest
|
|
22
|
+
// line for a project that is not public on the map);
|
|
19
23
|
// 5. the route: /g/<slug> on the hub's apex only, never on a child or the
|
|
20
24
|
// builders/status hosts, and a segment that cannot be a slug falls through.
|
|
21
25
|
// What the public roster may hold is the server's, and tests/privacy_matrix.mjs
|
|
@@ -67,7 +71,7 @@ test('portable and dark only: stamped title, noindex, balanced sentinels, no :ro
|
|
|
67
71
|
test('the script builds no markup from strings, never reads a GitHub id or login, and the regions ship hidden', () => {
|
|
68
72
|
assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML/, 'live DOM is textContent / createElement only');
|
|
69
73
|
assert.doesNotMatch(SCRIPT, /github_(id|login)/, 'a roster shows handle, display name, avatar and kind — never a GitHub id or login (ADR 0336 D4)');
|
|
70
|
-
for (const id of ['guildBody', 'missing', 'missingSignin', 'gYou', 'showAsk', 'shownUnlisted', 'gRosterEmpty']) {
|
|
74
|
+
for (const id of ['guildBody', 'missing', 'missingSignin', 'gYou', 'showAsk', 'shownUnlisted', 'gRosterEmpty', 'gApply']) {
|
|
71
75
|
assert.match(PAGE, new RegExp(`id="${id}"[^>]*\\bhidden\\b`), `#${id} ships hidden`);
|
|
72
76
|
}
|
|
73
77
|
for (const [name, values] of [['shown', ['on', 'off']], ['counted', ['on', 'off']]]) {
|
|
@@ -76,15 +80,16 @@ test('the script builds no markup from strings, never reads a GitHub id or login
|
|
|
76
80
|
for (const id of ['askYes', 'askNo', 'youSave', 'gRoster', 'tShipped', 'tCredits', 'tKarma', 'tProjects']) assert.match(PAGE, new RegExp(`id="${id}"`));
|
|
77
81
|
});
|
|
78
82
|
|
|
79
|
-
test('the page calls only the
|
|
83
|
+
test('the page calls only the four guild routes; the bar’s two calls stay in the shared asset', () => {
|
|
80
84
|
const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST|DELETE)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
81
|
-
assert.deepEqual([...new Set(calls)].sort(), ['GET /guilds/', 'PATCH /guilds/'].sort(), 'every call is built on /guilds/<slug>');
|
|
85
|
+
assert.deepEqual([...new Set(calls)].sort(), ['GET /guilds/', 'PATCH /guilds/', 'POST /guilds/'].sort(), 'every call is built on /guilds/<slug>');
|
|
82
86
|
assert.deepEqual([...SCRIPT.matchAll(/request\('[A-Z]+', '\/guilds\/' \+ slug( \+ '[^']*')?/g)].map((m) => m[0]).sort(), [
|
|
83
87
|
"request('GET', '/guilds/' + slug",
|
|
84
88
|
"request('GET', '/guilds/' + slug",
|
|
85
89
|
"request('GET', '/guilds/' + slug + '/members'",
|
|
86
90
|
"request('GET', '/guilds/' + slug + '/members'",
|
|
87
91
|
"request('PATCH', '/guilds/' + slug + '/membership'",
|
|
92
|
+
"request('POST', '/guilds/' + slug + '/engagements'",
|
|
88
93
|
].sort());
|
|
89
94
|
assert.match(PAGE, /<script src="\/assets\/hub-bar\.js"><\/script>\n<script>\n\(function \(\) \{/, 'hub-bar.js runs first, so window.HubBar exists when the page script asks for it');
|
|
90
95
|
assert.doesNotMatch(SCRIPT, /function (openBand|closeMenus|openMenu)\b|\/auth\/logout/, 'no second copy of the bands or sign out in the page');
|
|
@@ -488,6 +493,88 @@ test('an unlisted guild, to its members: no public page, so no pair, no prompt,
|
|
|
488
493
|
'a visibility change flips only the owner’s own flag');
|
|
489
494
|
});
|
|
490
495
|
|
|
496
|
+
// ── 4b. apply to a project, as this guild (task 1002303, R27; ADR 0365 D2) ──
|
|
497
|
+
// The owner files the guild's application; it applies for nobody, so each member
|
|
498
|
+
// (the owner included) applies as themselves from their invitations. A project
|
|
499
|
+
// that is unknown, off the map or not public is ONE 404 on the server, and one
|
|
500
|
+
// line here that never says a private project exists.
|
|
501
|
+
|
|
502
|
+
function ownerAnswer(engage) {
|
|
503
|
+
const members = (m, p, body) => memberAnswer({ you: { membership_kind: 'owner' } })(m, p, body);
|
|
504
|
+
return (m, p, body) => (p === '/guilds/forge-crew/engagements' && m === 'POST' ? engage(body) : members(m, p, body));
|
|
505
|
+
}
|
|
506
|
+
const ENGAGED = (origin) => ({
|
|
507
|
+
status: 200,
|
|
508
|
+
data: { engagement: { guild: { slug: 'forge-crew', name: 'Forge Crew' }, kind: 'apply', project: { name: 'Tidepool', origin }, created_at: '2026-10-05T09:00:00.000Z' } },
|
|
509
|
+
});
|
|
510
|
+
const sendApply = (dom, value) => {
|
|
511
|
+
dom.el('applyOrigin').value = value;
|
|
512
|
+
for (const f of dom.el('applyForm')._on.submit || []) f({ preventDefault() {} });
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
test('apply to a project: shown to the guild’s owner alone — never to a member, a stranger or someone signed out', async () => {
|
|
516
|
+
const owner = boot({ me: ME, answer: ownerAnswer(() => NOT_FOUND) });
|
|
517
|
+
await settle();
|
|
518
|
+
assert.equal(owner.el('gApply').hidden, false, 'the owner files for the guild');
|
|
519
|
+
|
|
520
|
+
const member = boot({ me: ME, answer: memberAnswer({}) });
|
|
521
|
+
await settle();
|
|
522
|
+
assert.equal(member.el('gApply').hidden, true, 'a member applies from their own invitations, not here');
|
|
523
|
+
|
|
524
|
+
const stranger = boot({ me: ME, answer: (m, p) => (p === '/guilds/forge-crew' ? { status: 200, data: PUBLIC() } : NOT_FOUND) });
|
|
525
|
+
await settle();
|
|
526
|
+
assert.equal(stranger.el('gApply').hidden, true);
|
|
527
|
+
|
|
528
|
+
const out = boot({ answer: (m, p) => (p === '/guilds/forge-crew' ? { status: 200, data: PUBLIC() } : NOT_FOUND) });
|
|
529
|
+
await settle();
|
|
530
|
+
assert.equal(out.el('gApply').hidden, true);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('apply to a project: sends exactly { origin } (a bare host taken as https://), and says it applied for nobody, the owner included', async () => {
|
|
534
|
+
const sent = [];
|
|
535
|
+
const dom = boot({ me: ME, answer: ownerAnswer((body) => { sent.push(body); return ENGAGED(body.origin); }) });
|
|
536
|
+
await settle();
|
|
537
|
+
|
|
538
|
+
sendApply(dom, ' ');
|
|
539
|
+
assert.equal(sent.length, 0, 'an empty field is a question, not a request');
|
|
540
|
+
assert.equal(dom.text('applyMsg'), 'Enter the project’s address.');
|
|
541
|
+
assert.equal(dom.el('applyMsg').className, 'projMsg err');
|
|
542
|
+
|
|
543
|
+
sendApply(dom, ' tidepool.example.com ');
|
|
544
|
+
await settle();
|
|
545
|
+
assert.deepEqual(sent, [{ origin: 'https://tidepool.example.com' }], 'the one field the route reads, and nothing else');
|
|
546
|
+
assert.deepEqual(dom.calls.filter((c) => c.method === 'POST').map((c) => c.path), ['/guilds/forge-crew/engagements']);
|
|
547
|
+
assert.equal(dom.text('applyMsg'), 'Done. Forge Crew’s members will see Tidepool in their invitations, and each one applies as themselves. That includes you — this didn’t apply for you.');
|
|
548
|
+
assert.equal(dom.el('applyMsg').className, 'projMsg ok');
|
|
549
|
+
assert.equal(dom.el('applyOrigin').value, '', 'the field clears for the next one');
|
|
550
|
+
assert.equal(dom.el('applySend').disabled, false);
|
|
551
|
+
assert.equal(dom.calls.filter((c) => c.path === '/my-projects/join').length, 0, 'filing never applies on anyone’s behalf');
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test('apply to a project: a project that is not public on the map is ONE honest line; other refusals in the server’s words', async () => {
|
|
555
|
+
let answer = { status: 404, data: { error: { code: 'unknown_project', message: 'unknown_project' } } };
|
|
556
|
+
const dom = boot({ me: ME, answer: ownerAnswer(() => answer) });
|
|
557
|
+
await settle();
|
|
558
|
+
|
|
559
|
+
sendApply(dom, 'https://stealth.example.com');
|
|
560
|
+
await settle();
|
|
561
|
+
assert.equal(dom.text('applyMsg'), 'No public project on the map at that address.', 'unknown, private and stealth read the same');
|
|
562
|
+
assert.doesNotMatch(dom.text('applyMsg'), /private|stealth|exists/i);
|
|
563
|
+
assert.equal(dom.el('applyMsg').className, 'projMsg err');
|
|
564
|
+
assert.equal(dom.el('applyOrigin').value, 'https://stealth.example.com', 'a refusal keeps what was typed');
|
|
565
|
+
|
|
566
|
+
answer = { status: 403, data: { error: { code: 'guild_owner_only', message: 'only the guild owner can do that' } } };
|
|
567
|
+
sendApply(dom, 'https://tidepool.example.com');
|
|
568
|
+
await settle();
|
|
569
|
+
assert.equal(dom.text('applyMsg'), 'Only the guild owner can do that.');
|
|
570
|
+
|
|
571
|
+
answer = { status: 401, data: null };
|
|
572
|
+
sendApply(dom, 'https://tidepool.example.com');
|
|
573
|
+
await settle();
|
|
574
|
+
assert.equal(dom.text('applyMsg'), 'Your session expired — sign in again to send this.');
|
|
575
|
+
assert.equal(dom.el('who').hidden, true, 'the chip is repainted signed out');
|
|
576
|
+
});
|
|
577
|
+
|
|
491
578
|
// ── 5. the route ─────────────────────────────────────────────────────────────
|
|
492
579
|
|
|
493
580
|
const { buildPlatformApp } = require('../src/platform-server.js');
|
package/tests/privacy_matrix.mjs
CHANGED
|
@@ -160,14 +160,19 @@ function identitySentinels(acct) {
|
|
|
160
160
|
// members GET /guilds/:slug/members a fellow Member — the own-scope roster: 'listed' |
|
|
161
161
|
// 'absent'. Joining is consent to be seen BY THE GUILD (D4 §1), so a private
|
|
162
162
|
// member is listed here and nowhere public; a provisional account is never
|
|
163
|
+
// invite POST /projects/invite-guild a project's Inviter (task 1002303; ADR 0365 D3) — one
|
|
164
|
+
// hall link per member of a PUBLIC guild's public roster: 'listed' | 'absent'.
|
|
165
|
+
// It reads accounts across projects, so it may hand the inviter exactly the
|
|
166
|
+
// people the guild page already shows them — shown_publicly AND existence;
|
|
167
|
+
// hide_stats never hides membership, and private or provisional is never listed
|
|
163
168
|
const MATRIX = {
|
|
164
|
-
PROV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'n/a', guild: 'absent', members: 'absent' },
|
|
165
|
-
PUB: { profile: 'full', search: 'listed', board: 'listed', directory: 'full', sliver: 'full', self: 'own', guild: 'listed', members: 'listed' },
|
|
166
|
-
SHY: { profile: 'identity', search: 'name-only', board: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own', guild: 'listed', members: 'listed' },
|
|
167
|
-
PRIV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'own', guild: 'absent', members: 'listed' },
|
|
168
|
-
PRIVD: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'full', self: 'own', guild: 'absent', members: 'listed' },
|
|
169
|
-
PRIVDH: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'name-only', self: 'own', guild: 'absent', members: 'listed' },
|
|
170
|
-
OPTOUT: { profile: 'full', search: 'listed', board: 'listed', directory: 'absent', sliver: 404, self: 'own', guild: 'listed', members: 'listed' },
|
|
169
|
+
PROV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'n/a', guild: 'absent', members: 'absent', invite: 'absent' },
|
|
170
|
+
PUB: { profile: 'full', search: 'listed', board: 'listed', directory: 'full', sliver: 'full', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
171
|
+
SHY: { profile: 'identity', search: 'name-only', board: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
172
|
+
PRIV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
173
|
+
PRIVD: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'full', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
174
|
+
PRIVDH: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'name-only', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
175
|
+
OPTOUT: { profile: 'full', search: 'listed', board: 'listed', directory: 'absent', sliver: 404, self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
171
176
|
};
|
|
172
177
|
|
|
173
178
|
// ===========================================================================
|
|
@@ -271,10 +276,40 @@ function guildRosterFilter(sql, params) {
|
|
|
271
276
|
return (m) => m.guild_id === String(params[0]) && (!shownOnly || m.shown_publicly) && byAccount(ACCOUNTS.find((a) => a.github_id === m.github_id));
|
|
272
277
|
}
|
|
273
278
|
|
|
279
|
+
// THE PROJECT a guild is invited to (task 1002303): one active client, whose
|
|
280
|
+
// origin the hall links point at.
|
|
281
|
+
const MX_CLIENT = { client_id: 'mx-proj', secret_hash: '', name: 'MX Project', redirect_uris: [], origin: 'https://mx-proj.example', owner_github_id: null, status: 'active' };
|
|
282
|
+
|
|
274
283
|
const pool = {
|
|
275
284
|
query: async (text, params = []) => {
|
|
276
285
|
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
277
286
|
|
|
287
|
+
// POST /projects/invite-guild's reads (task 1002303). The client the gate resolves.
|
|
288
|
+
if (/FROM platform_identity_sso_clients WHERE client_id = \$1$/.test(sql)) {
|
|
289
|
+
const rows = params[0] === MX_CLIENT.client_id ? [{ ...MX_CLIENT }] : [];
|
|
290
|
+
return { rows, rowCount: rows.length };
|
|
291
|
+
}
|
|
292
|
+
// ... the guild, by slug — its WHERE is read, not assumed: PUBLIC only.
|
|
293
|
+
if (/^SELECT id, slug, name FROM platform_identity_guilds WHERE /.test(sql)) {
|
|
294
|
+
if (!/WHERE lower\(slug\) = \$1 AND visibility = 'public'$/.test(sql)) throw new Error(`fake pool: the invite-guild lookup moved — ${sql}`);
|
|
295
|
+
const rows = GUILDS.filter((g) => g.slug === String(params[0]) && g.visibility === 'public').map(({ id, slug, name }) => ({ id, slug, name }));
|
|
296
|
+
return { rows, rowCount: rows.length };
|
|
297
|
+
}
|
|
298
|
+
// ... the `invited` engagement it records (no account in it, so nothing to model here).
|
|
299
|
+
if (/^INSERT INTO platform_identity_guild_engagements /.test(sql)) return { rows: [], rowCount: 1 };
|
|
300
|
+
// ... and the PUBLIC roster the hall links go to, read through the same roster
|
|
301
|
+
// interpreter as the guild page — its projection pinned, since the login it
|
|
302
|
+
// reads becomes part of each link.
|
|
303
|
+
if (/^SELECT pa\.handle, pa\.display_name, pa\.github_login FROM platform_identity_guild_members m JOIN platform_identity_accounts pa/.test(sql)) {
|
|
304
|
+
const keep = guildRosterFilter(sql, params);
|
|
305
|
+
const rows = GUILD_MEMBERS.filter(keep).map((m) => {
|
|
306
|
+
const a = ACCOUNTS.find((x) => x.github_id === m.github_id);
|
|
307
|
+
// github_id and the bio ride along so a projection that spreads the row leaks them
|
|
308
|
+
return { handle: a.handle, display_name: a.display_name, github_login: a.github_login, github_id: a.github_id, bio: a.bio };
|
|
309
|
+
});
|
|
310
|
+
return { rows, rowCount: rows.length };
|
|
311
|
+
}
|
|
312
|
+
|
|
278
313
|
// THE GUILD PAGE's reads. The public guild: its WHERE is read, not assumed.
|
|
279
314
|
if (/^SELECT id, slug, name, description, created_at FROM platform_identity_guilds WHERE /.test(sql)) {
|
|
280
315
|
if (!/WHERE lower\(slug\) = \$1 AND visibility = 'public'$/.test(sql)) throw new Error(`fake pool: the public guild read moved — ${sql}`);
|
|
@@ -424,6 +459,13 @@ require.cache[moduleApiPath] = {
|
|
|
424
459
|
},
|
|
425
460
|
requirePermission: () => (req, res, next) => (viewer && viewer.curate ? next() : res.status(403).json({ ok: false, error: 'forbidden' })),
|
|
426
461
|
requireRank: (...allowed) => (req, res, next) => (viewer && allowed.includes(viewer.rank) ? next() : res.fail('rank_forbidden', 403)),
|
|
462
|
+
// routes/projects.js (POST /projects/invite-guild): its seams are already
|
|
463
|
+
// registered as far as this file is concerned, and no provisioning module
|
|
464
|
+
// answers the inviter gate's ownership arm, so the rank door decides.
|
|
465
|
+
hasProvider: () => true,
|
|
466
|
+
registerProvider: () => {},
|
|
467
|
+
resolveOptional: () => null,
|
|
468
|
+
publicProjectFeedRateLimit: (_req, _res, next) => next(),
|
|
427
469
|
},
|
|
428
470
|
};
|
|
429
471
|
|
|
@@ -471,7 +513,7 @@ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
|
471
513
|
const app = express();
|
|
472
514
|
app.use(express.json());
|
|
473
515
|
app.use(attachFail);
|
|
474
|
-
for (const file of ['public-profile', 'profile', 'community-search', 'community-leaderboard', 'scouting', 'guilds-public', 'guilds']) {
|
|
516
|
+
for (const file of ['public-profile', 'profile', 'community-search', 'community-leaderboard', 'scouting', 'guilds-public', 'guilds', 'projects']) {
|
|
475
517
|
app.use('/api/bongos', require(`../modules/platform-identity/routes/${file}.js`)());
|
|
476
518
|
}
|
|
477
519
|
const server = app.listen(0, '127.0.0.1');
|
|
@@ -503,6 +545,28 @@ function get(who, path_) {
|
|
|
503
545
|
});
|
|
504
546
|
}
|
|
505
547
|
|
|
548
|
+
/** A POST through the same recorder, so the sweeps below judge its body too. */
|
|
549
|
+
function post(who, path_, body) {
|
|
550
|
+
viewer = who;
|
|
551
|
+
const payload = JSON.stringify(body);
|
|
552
|
+
const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) };
|
|
553
|
+
return new Promise((resolve, reject) => {
|
|
554
|
+
const req = http.request({ host: '127.0.0.1', port, method: 'POST', path: path_, headers }, (res) => {
|
|
555
|
+
const chunks = [];
|
|
556
|
+
res.on('data', (c) => chunks.push(c));
|
|
557
|
+
res.on('end', () => {
|
|
558
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
559
|
+
let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
|
|
560
|
+
const out = { who: who ? who.name : 'Anon', whoId: who ? who.github_id : null, path: path_, status: res.statusCode, text, json };
|
|
561
|
+
SEEN.push(out);
|
|
562
|
+
resolve(out);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
req.on('error', reject);
|
|
566
|
+
req.end(payload);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
506
570
|
// ===========================================================================
|
|
507
571
|
// 0. The instrument is calibrated before it is trusted
|
|
508
572
|
// ===========================================================================
|
|
@@ -639,6 +703,32 @@ test('an unlisted guild’s public read is a 404 to everyone, its own members in
|
|
|
639
703
|
for (const k of KEYS) assert.equal(listed.has(ACCT[k].handle), MATRIX[k].members === 'listed', `${k} on the unlisted guild’s members’ roster`);
|
|
640
704
|
});
|
|
641
705
|
|
|
706
|
+
// A project invites a guild (task 1002303; ADR 0365 D3). The INVITER is the
|
|
707
|
+
// viewer here — a holder of the route's rank door (the Recruiter fixture carries
|
|
708
|
+
// it); the cell is who they are handed a link for.
|
|
709
|
+
const INVITE_GUILD_PATH = '/api/bongos/projects/invite-guild';
|
|
710
|
+
const INVITE_KEYS = ['display_name', 'handle', 'invite_url'];
|
|
711
|
+
|
|
712
|
+
test('a project invites a guild — one hall link per PUBLIC-roster member, listed or absent per the matrix; a non-inviter is refused', async () => {
|
|
713
|
+
const r = await post(RECRUITER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-guild' });
|
|
714
|
+
assert.equal(r.status, 200);
|
|
715
|
+
assert.equal(r.json.invited, false, 'the hub writes no invite (ADR 0353)');
|
|
716
|
+
const listed = new Set(r.json.invites.map((i) => i.handle));
|
|
717
|
+
for (const k of KEYS) assert.equal(listed.has(ACCT[k].handle), MATRIX[k].invite === 'listed', `${k} in the invite fan-out: expected ${MATRIX[k].invite}`);
|
|
718
|
+
assert.equal(r.json.count, r.json.invites.length, 'count is the array’s own length');
|
|
719
|
+
for (const i of r.json.invites) {
|
|
720
|
+
assert.deepEqual(Object.keys(i).sort(), INVITE_KEYS, 'a handle, a name and a link — never a GitHub id, a bio or a number');
|
|
721
|
+
const a = ACCOUNTS.find((x) => x.handle === i.handle);
|
|
722
|
+
assert.equal(i.invite_url, `${MX_CLIENT.origin}/builders/watch?invite=${encodeURIComponent(a.github_login)}`, 'the project’s own hall, with the login a public profile already shows');
|
|
723
|
+
}
|
|
724
|
+
const stranger = await post(STRANGER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-guild' });
|
|
725
|
+
assert.equal(stranger.status, 403, 'only the project’s inviter is handed the roster');
|
|
726
|
+
const unlisted = await post(RECRUITER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-unlisted' });
|
|
727
|
+
const nobody = await post(RECRUITER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-nobody' });
|
|
728
|
+
assert.equal(unlisted.status, 404);
|
|
729
|
+
assert.equal(unlisted.text, nobody.text, 'an unlisted guild cannot be invited: the unknown-guild 404, byte for byte');
|
|
730
|
+
});
|
|
731
|
+
|
|
642
732
|
test('the sliver’s key lists are D3’s, transcribed: handle, name, avatar, disciplines, a recent-activity boolean — or the first three', () => {
|
|
643
733
|
// The cells above compare a response to the MODULE's lists; this pins those
|
|
644
734
|
// lists to the spec, so a sixth key added to the module cannot pass with them.
|
|
@@ -716,7 +806,7 @@ function disclosed(r) {
|
|
|
716
806
|
}
|
|
717
807
|
|
|
718
808
|
test('the walk covered every surface for every row (so the claims below are about something)', () => {
|
|
719
|
-
for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/scouting', '/guilds/mx-guild', '/guilds/mx-guild/members']) {
|
|
809
|
+
for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/scouting', '/guilds/mx-guild', '/guilds/mx-guild/members', '/projects/invite-guild']) {
|
|
720
810
|
assert.ok(SEEN.some((r) => r.path.includes(p) && r.status === 200), `no 200 from ${p} — the sweep below would be vacuous for it`);
|
|
721
811
|
}
|
|
722
812
|
assert.ok(SEEN.length >= KEYS.length * 6, `only ${SEEN.length} responses recorded`);
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
// • a refusal never costs the project or the step — the box stays, re-tryable
|
|
30
30
|
// • Skip advances with ZERO calls
|
|
31
31
|
// • both call sites hand over a slug only for a signed-in owner
|
|
32
|
+
// • a GUILD can be invited from the project's own page (task 1002303, R27; ADR
|
|
33
|
+
// 0365 D3): exactly { client_id, guild } to /projects/invite-guild, its answer
|
|
34
|
+
// drawn as the same hand-off lines, one per member on the guild's public page,
|
|
35
|
+
// an unknown or unlisted guild one plain line, and the wizard has no such door
|
|
32
36
|
//
|
|
33
37
|
// Run: node --test --test-reporter=tap tests/project_invite_ui.mjs
|
|
34
38
|
|
|
@@ -51,7 +55,8 @@ assert.ok(START > 0, 'the invite region must start at INVITE_MOUNTS');
|
|
|
51
55
|
assert.ok(END > START, 'the invite region must end before the boot block');
|
|
52
56
|
const REGION = SCRIPT.slice(START, END);
|
|
53
57
|
for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function skipInvite(', 'function inviteRefusal(',
|
|
54
|
-
'function setInviteSuggestions(', 'function renderInviteSugg(', 'function loadInviteSuggestions('
|
|
58
|
+
'function setInviteSuggestions(', 'function renderInviteSugg(', 'function loadInviteSuggestions(',
|
|
59
|
+
'function doInviteGuild(', 'function inviteGuildSlug(']) {
|
|
55
60
|
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -116,7 +121,7 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
116
121
|
['wizInvite', 'wizInviteWho', 'wizInviteList', 'wizInviteMsg', 'wizInviteLater', 'doneStepsHead',
|
|
117
122
|
'wizInviteWhy', 'wizInviteSugg',
|
|
118
123
|
'mInvite', 'mInviteWho', 'mInviteList', 'mInviteMsg',
|
|
119
|
-
'mInviteWhy', 'mInviteSugg'].forEach($);
|
|
124
|
+
'mInviteWhy', 'mInviteSugg', 'mInviteGuild', 'mInviteGuildMsg'].forEach($);
|
|
120
125
|
|
|
121
126
|
const queued = answers.slice();
|
|
122
127
|
const fetch = (url, init = {}) => {
|
|
@@ -150,7 +155,7 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
150
155
|
|
|
151
156
|
const mounts = {
|
|
152
157
|
wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', why: 'wizInviteWhy', sugg: 'wizInviteSugg' },
|
|
153
|
-
mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', why: 'mInviteWhy', sugg: 'mInviteSugg' },
|
|
158
|
+
mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', why: 'mInviteWhy', sugg: 'mInviteSugg', guild: 'mInviteGuild', guildMsg: 'mInviteGuildMsg' },
|
|
154
159
|
};
|
|
155
160
|
const m = mounts[mount];
|
|
156
161
|
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
|
@@ -186,6 +191,22 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
186
191
|
async suggest(payload) { sandbox.setInviteSuggestions(mount, payload); await settle(); },
|
|
187
192
|
async load(instanceId) { sandbox.loadInviteSuggestions(mount, instanceId); await settle(); },
|
|
188
193
|
async pick(login) { pickLogin = login; await press('data-invite-pick'); },
|
|
194
|
+
// ── the guild half (task 1002303, R27) ───────────────────────────────
|
|
195
|
+
guildField: () => $('mInviteGuild'),
|
|
196
|
+
guildMsg: () => $('mInviteGuildMsg'),
|
|
197
|
+
async inviteGuild(value) {
|
|
198
|
+
$('mInviteGuild').value = value;
|
|
199
|
+
const handlers = listeners.get(mount + ':click') || [];
|
|
200
|
+
assert.equal(handlers.length, 1, 'the guild button rides the mount’s one delegated click handler');
|
|
201
|
+
handlers[0]({ target: { closest: (sel) => (sel === '[data-invite-guild]' ? {} : null) } });
|
|
202
|
+
await settle();
|
|
203
|
+
},
|
|
204
|
+
async guildEnter() {
|
|
205
|
+
const handlers = listeners.get('mInviteGuild:keydown') || [];
|
|
206
|
+
assert.equal(handlers.length, 1, 'the guild field takes Enter');
|
|
207
|
+
handlers[0]({ key: 'Enter', preventDefault() {} });
|
|
208
|
+
await settle();
|
|
209
|
+
},
|
|
189
210
|
async enter() {
|
|
190
211
|
const handlers = listeners.get(m.who + ':keydown') || [];
|
|
191
212
|
assert.equal(handlers.length, 1, 'the field takes Enter');
|
|
@@ -708,3 +729,166 @@ test('the suggestion surface is mounted on BOTH the wizard and the project page'
|
|
|
708
729
|
assert.match(COSMOS, /\.suggList\{/, 'styled in cosmos.css like its sibling list');
|
|
709
730
|
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>') + 1), /suggList/, 'not in the page head');
|
|
710
731
|
});
|
|
732
|
+
|
|
733
|
+
// ── invite a guild (task 1002303, R27; ADR 0365 D3) ──────────────────────────
|
|
734
|
+
// The project's own page names a public guild; the route records that the project
|
|
735
|
+
// invited it and answers one hall hand-off link per member on the guild's PUBLIC
|
|
736
|
+
// page — the people the inviter could see. Nothing is sent from the hub: each
|
|
737
|
+
// becomes the same pending line a single invite gets, finished on the hall.
|
|
738
|
+
|
|
739
|
+
const guildAnswer = (members = [['mara-quinn', 'Mara Quinn'], ['kip', null]], name = 'Forge Crew') => ({
|
|
740
|
+
ok: true, status: 200,
|
|
741
|
+
body: {
|
|
742
|
+
invited: false,
|
|
743
|
+
guild: { slug: 'forge-crew', name },
|
|
744
|
+
invites: members.map(([handle, display]) => ({ handle, display_name: display, invite_url: 'https://mercury.cloudbongos.test/builders/watch?invite=' + handle })),
|
|
745
|
+
count: members.length,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test('inviteGuildSlug reads a guild the ways a person pastes one: its name, /g/<name>, or the page’s whole address', () => {
|
|
750
|
+
const f = boot().sandbox.inviteGuildSlug;
|
|
751
|
+
assert.equal(f('forge-crew'), 'forge-crew');
|
|
752
|
+
assert.equal(f(' Forge-Crew '), 'forge-crew', 'trimmed and lowercased, as the slug rule is');
|
|
753
|
+
assert.equal(f('/g/forge-crew'), 'forge-crew');
|
|
754
|
+
assert.equal(f('g/forge-crew/'), 'forge-crew');
|
|
755
|
+
assert.equal(f('https://cloudbongos.com/g/Forge-Crew/?ref=x#members'), 'forge-crew');
|
|
756
|
+
assert.equal(f(''), '');
|
|
757
|
+
assert.equal(f(null), '');
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test('a guild invite sends exactly { client_id, guild } to /projects/invite-guild, on the cookie session', async () => {
|
|
761
|
+
const ui = boot({ mount: 'mInvite', answers: [guildAnswer()] });
|
|
762
|
+
await ui.open('mercury');
|
|
763
|
+
|
|
764
|
+
await ui.inviteGuild('https://cloudbongos.com/g/forge-crew');
|
|
765
|
+
|
|
766
|
+
assert.equal(ui.requests.length, 1);
|
|
767
|
+
const req = ui.requests[0];
|
|
768
|
+
assert.equal(req.url, '/api/bongos/projects/invite-guild');
|
|
769
|
+
assert.equal(req.method, 'POST');
|
|
770
|
+
assert.equal(req.credentials, 'same-origin');
|
|
771
|
+
assert.deepEqual(JSON.parse(req.body), { client_id: 'mercury', guild: 'forge-crew' }, 'the slug the route reads, and nothing else');
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test('the answer is one hand-off line per member on the guild’s public page — the same lines a single invite gets', async () => {
|
|
775
|
+
// a hand-off link that is not http(s) is never drawn, whoever it belongs to
|
|
776
|
+
const queued = guildAnswer([['mara-quinn', 'Mara Quinn'], ['kip', null]]);
|
|
777
|
+
queued.body.invites.push({ handle: 'bad', display_name: 'Bad Link', invite_url: 'javascript:alert(1)' });
|
|
778
|
+
const h = boot({ mount: 'mInvite', answers: [queued] });
|
|
779
|
+
await h.open('mercury');
|
|
780
|
+
|
|
781
|
+
await h.inviteGuild('forge-crew');
|
|
782
|
+
|
|
783
|
+
assert.deepEqual(h.rows().map((r) => r.login), ['Mara Quinn', 'kip'], 'the display name the guild page shows, else the handle');
|
|
784
|
+
assert.deepEqual(h.rows().map((r) => r.url), [
|
|
785
|
+
'https://mercury.cloudbongos.test/builders/watch?invite=mara-quinn',
|
|
786
|
+
'https://mercury.cloudbongos.test/builders/watch?invite=kip',
|
|
787
|
+
], 'each link is the route’s own invite_url, verbatim');
|
|
788
|
+
for (const r of h.rows()) assert.match(r.said, /not sent yet/i, 'no line claims an invite the hall has not sent');
|
|
789
|
+
assert.doesNotMatch(h.sandbox.$('mInviteList').innerHTML, /javascript:/);
|
|
790
|
+
assert.equal(h.guildMsg().className, 'projMsg ok');
|
|
791
|
+
assert.equal(h.guildMsg().textContent, 'Forge Crew: 2 members to invite. Send each invite from your project’s hall — every line above opens it with their name filled in.');
|
|
792
|
+
assert.equal(h.guildField().value, '', 'the field clears for the next one');
|
|
793
|
+
assert.equal(h.msg().textContent, '', 'the single-invite line is left alone');
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test('inviting the same guild twice draws no second line for anyone already on one', async () => {
|
|
797
|
+
const ui = boot({ mount: 'mInvite', answers: [guildAnswer(), guildAnswer()] });
|
|
798
|
+
await ui.open('mercury');
|
|
799
|
+
|
|
800
|
+
await ui.inviteGuild('forge-crew');
|
|
801
|
+
await ui.inviteGuild('forge-crew');
|
|
802
|
+
|
|
803
|
+
assert.deepEqual(ui.rows().map((r) => r.login), ['Mara Quinn', 'kip']);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('a guild with nobody on its public page says so plainly, and draws nothing', async () => {
|
|
807
|
+
const ui = boot({ mount: 'mInvite', answers: [guildAnswer([])] });
|
|
808
|
+
await ui.open('mercury');
|
|
809
|
+
|
|
810
|
+
await ui.inviteGuild('forge-crew');
|
|
811
|
+
|
|
812
|
+
assert.equal(ui.rows().length, 0);
|
|
813
|
+
assert.equal(ui.guildMsg().className, 'projMsg', 'an answer, not a failure');
|
|
814
|
+
assert.equal(ui.guildMsg().textContent, 'Nobody is listed on Forge Crew’s public page yet, so there is no one to invite from here.');
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test('an unknown guild and an unlisted one are ONE plain line; the other refusals keep the widget’s vocabulary', async () => {
|
|
818
|
+
const ui = boot({ mount: 'mInvite', answers: [
|
|
819
|
+
refused(404, 'guild_not_found', 'guild_not_found'),
|
|
820
|
+
refused(403, 'permission_forbidden', 'requires project.curate'),
|
|
821
|
+
refused(401, 'unauthenticated'),
|
|
822
|
+
{ reject: true },
|
|
823
|
+
refused(429, 'rate_limited', 'Too many invitations right now — give it a minute.'),
|
|
824
|
+
] });
|
|
825
|
+
await ui.open('mercury');
|
|
826
|
+
|
|
827
|
+
await ui.inviteGuild('night-shift');
|
|
828
|
+
assert.equal(ui.guildMsg().textContent, 'No public guild at that name.');
|
|
829
|
+
assert.equal(ui.guildMsg().className, 'projMsg err');
|
|
830
|
+
assert.equal(ui.guildField().value, 'night-shift', 'a refusal keeps what was typed');
|
|
831
|
+
|
|
832
|
+
await ui.inviteGuild('forge-crew');
|
|
833
|
+
assert.match(ui.guildMsg().textContent, /can’t invite/i, 'the 403 said the careful way (ADR 0248)');
|
|
834
|
+
assert.doesNotMatch(ui.guildMsg().textContent, /project\.curate/);
|
|
835
|
+
|
|
836
|
+
await ui.inviteGuild('forge-crew');
|
|
837
|
+
assert.match(ui.guildMsg().innerHTML, /sign in again/i);
|
|
838
|
+
|
|
839
|
+
await ui.inviteGuild('forge-crew');
|
|
840
|
+
assert.match(ui.guildMsg().textContent, /reach the platform/i);
|
|
841
|
+
|
|
842
|
+
await ui.inviteGuild('forge-crew');
|
|
843
|
+
assert.match(ui.guildMsg().textContent, /Too many invitations right now/);
|
|
844
|
+
assert.equal(ui.rows().length, 0, 'no refusal draws a line');
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test('an empty guild field is a question rather than a request; Enter in the field sends too', async () => {
|
|
848
|
+
const ui = boot({ mount: 'mInvite', answers: [guildAnswer()] });
|
|
849
|
+
await ui.open('mercury');
|
|
850
|
+
|
|
851
|
+
await ui.inviteGuild(' ');
|
|
852
|
+
assert.equal(ui.requests.length, 0);
|
|
853
|
+
assert.equal(ui.guildMsg().textContent, 'Type the guild’s name or its /g/ address.');
|
|
854
|
+
assert.ok(ui.focused.includes('mInviteGuild'), 'focus goes to the field it asks about');
|
|
855
|
+
|
|
856
|
+
ui.guildField().value = 'forge-crew';
|
|
857
|
+
await ui.guildEnter();
|
|
858
|
+
assert.equal(ui.requests.length, 1);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test('the guild door is the project page’s alone: the wizard’s step has none, and a closed mount sends nothing', async () => {
|
|
862
|
+
const wiz = boot({ mount: 'wizInvite' });
|
|
863
|
+
await wiz.open('mercury');
|
|
864
|
+
await wiz.inviteGuild('forge-crew');
|
|
865
|
+
assert.equal(wiz.requests.length, 0, 'the wizard mount names no guild field');
|
|
866
|
+
|
|
867
|
+
const closed = boot({ mount: 'mInvite' });
|
|
868
|
+
await closed.open('');
|
|
869
|
+
await closed.inviteGuild('forge-crew');
|
|
870
|
+
assert.equal(closed.requests.length, 0, 'no project, no request');
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test('a new project starts the guild line clean too', async () => {
|
|
874
|
+
const ui = boot({ mount: 'mInvite', answers: [refused(404, 'guild_not_found')] });
|
|
875
|
+
await ui.open('mercury');
|
|
876
|
+
await ui.inviteGuild('night-shift');
|
|
877
|
+
assert.notEqual(ui.guildMsg().textContent, '');
|
|
878
|
+
|
|
879
|
+
await ui.open('venus');
|
|
880
|
+
assert.equal(ui.guildMsg().textContent, '', 'the answer belonged to the project it was asked for');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test('the project’s own page carries the guild door: a field, its button, and the one-line why — as real markup', () => {
|
|
884
|
+
const sec = (HUB.match(/<div class="card mSec" id="mInvite">[\s\S]*?\n <\/div>/) || [''])[0];
|
|
885
|
+
assert.match(sec, /<div class="attach">\s*<label class="vh" for="mInviteGuild">Guild name or address<\/label>\s*<input type="text" id="mInviteGuild"/, 'the page’s own input+button idiom');
|
|
886
|
+
assert.match(sec, /data-invite-guild="mInvite"/);
|
|
887
|
+
assert.match(sec, /Each member gets their own invite, and only the members shown on the guild’s public page are listed\./);
|
|
888
|
+
assert.match(sec, /<div class="projMsg" id="mInviteGuildMsg" role="status" aria-live="polite"><\/div>/, 'its answer has a line of its own, under the button');
|
|
889
|
+
assert.ok(sec.indexOf('id="mInviteGuild"') > sec.indexOf('id="mInviteList"'), 'below the pending lines it fills — "every line above"');
|
|
890
|
+
const panel = (HUB.match(/<section class="panel" id="panel-done"[\s\S]*?<\/section>/) || [''])[0];
|
|
891
|
+
assert.doesNotMatch(panel, /data-invite-guild/, 'the wizard’s step invites people, not guilds');
|
|
892
|
+
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
893
|
+
assert.ok(states['in-manage-invite-guild'], 'the guild lines have a kit state');
|
|
894
|
+
});
|