@bongos/core 1.21.62 → 1.21.64
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 +55 -40
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +7 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +343 -343
- package/docs/copy-registry.json +390 -390
- package/docs/module-api-changelog.md +9 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +753 -728
- package/modules/hall-ui/public/watch.css +15 -0
- package/modules/hall-ui/public/watch.html +3 -0
- package/modules/hall-ui/public/watch.js +87 -1
- package/modules/hall-ui/records/oversight-family.md +6 -0
- package/modules/onboarding/routes/access-requests.js +10 -4
- package/modules/platform-identity/guilds.js +32 -0
- package/modules/platform-identity/routes/guilds.js +10 -3
- package/modules/public-landing/public/assets/cosmos.css +29 -3
- package/modules/public-landing/public/projects.html +294 -4
- package/modules/public-landing/public/projects.probes.json +18 -0
- package/modules/public-landing/public/projects.states.json +3 -0
- package/modules/ui-design/kit/fixtures/guilds.json +7 -0
- package/modules/ui-design/kit/fixtures/me__guild-requests.json +9 -0
- package/modules/ui-design/kit/serve.js +41 -0
- package/package-lock.json +9 -5
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/auth-admission.js +33 -2
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +7 -1
- package/tests/applicant_view_guild.mjs +101 -0
- package/tests/application_lifecycle.mjs +38 -1
- package/tests/guilds_api.mjs +31 -0
- package/tests/guilds_db.mjs +7 -0
- package/tests/hub_engagement_cards.mjs +9 -6
- package/tests/hub_guild_cards.mjs +392 -0
- package/tests/module_api.mjs +1 -0
- package/tests/watch_applications_queue.mjs +112 -2
|
@@ -187,6 +187,24 @@
|
|
|
187
187
|
{ "name": "guild cards: the invited card applies on its own, with no note", "continue": true,
|
|
188
188
|
"steps": [["click","#engageList .engageRow:nth-child(2) .btn"],["wait",400]],
|
|
189
189
|
"expect": { "text": [["#engageList .engageRow:nth-child(2) .btn", "Applied"]], "hasClass": [["#engageList .engageRow:nth-child(2) .projMsg", "ok"]] } },
|
|
190
|
+
{"name": "guild invite cards (task 1004583): each says what joining shows and, before accept, that your numbers count — with the switch right there, set to count", "auth": true, "width": 1440, "url": "/projects?view=mine",
|
|
191
|
+
"steps": [["wait",900]],
|
|
192
|
+
"expect": {"visible": ["#guildInviteList", "#guildInviteList .guildRow:first-child .guildCount"], "count": [["#guildInviteList .guildRow", 2], ["#guildInviteList .guildRow:first-child a.linklike:not([hidden])", 1], ["#guildInviteList .guildRow:nth-child(2) a.linklike:not([hidden])", 0]], "text": [["#guildInviteList .guildRow:first-child .mineRow__name", "You’re invited to join Open Hands"], ["#guildInviteList .guildRow:first-child .guildCount__what", "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."]], "checked": "#guildInviteList .guildRow:first-child input[value=\"on\"]"} },
|
|
193
|
+
{"name": "guild invite: Leave them out says what that means before anything is sent", "continue": true,
|
|
194
|
+
"steps": [["click","#guildInviteList .guildRow:first-child .guildCount__opt:nth-child(2)"],["wait",150]],
|
|
195
|
+
"expect": {"checked": "#guildInviteList .guildRow:first-child input[value=\"off\"]", "text": [["#guildInviteList .guildRow:first-child .guildCount__what", "Your numbers are left out of this guild’s totals, from its next weekly count."]]} },
|
|
196
|
+
{"name": "guild invite: Accept sends the card's visibility and the switch (the stub refuses an accept without the card's visibility) and the card says where your numbers went", "continue": true,
|
|
197
|
+
"steps": [["click","#guildInviteList .guildRow:first-child .btn"],["wait",400]],
|
|
198
|
+
"expect": {"hasClass": [["#guildInviteList .guildRow:first-child .projMsg", "ok"], ["#guildInviteList .guildRow:first-child .btn", "ghost"]], "text": [["#guildInviteList .guildRow:first-child .btn", "Joined"], ["#guildInviteList .guildRow:first-child .projMsg", "You’ve joined Open Hands, and your numbers are left out of its totals."]]} },
|
|
199
|
+
{"name": "guild invite: Decline drops the card, silently", "continue": true,
|
|
200
|
+
"steps": [["click","#guildInviteList .guildRow:nth-child(2) .btn.ghost"],["wait",400]],
|
|
201
|
+
"expect": {"count": [["#guildInviteList .guildRow", 1]]} },
|
|
202
|
+
{"name": "join request (task 1004583): the owner of Night Shift accepts one and declines the other, from the hub", "continue": true,
|
|
203
|
+
"steps": [["click","#guildRequestList .guildRow:first-child .btn"],["wait",400],["click","#guildRequestList .guildRow:nth-child(2) .btn.ghost"],["wait",400]],
|
|
204
|
+
"expect": {"count": [["#guildRequestList .guildRow", 1]], "hasClass": [["#guildRequestList .guildRow:first-child .projMsg", "ok"]], "text": [["#guildRequestList .guildRow:first-child .btn", "Accepted"], ["#guildRequestList .guildRow:first-child .projMsg", "Tomás Ek is in Night Shift now."]]} },
|
|
205
|
+
{"name": "guild cards at 320: the switch stacks and nothing scrolls sideways", "auth": true, "width": 320, "url": "/projects?view=mine",
|
|
206
|
+
"steps": [["wait",900]],
|
|
207
|
+
"expect": {"noOverflow": true, "visible": ["#guildInviteList .guildRow:first-child .guildCount"]} },
|
|
190
208
|
{ "name": "invite a guild (task 1002303, R27): a /g/ address gives one pending line per member on its public page, each a link to the hall", "auth": true, "width": 1440, "url": "/projects?view=manage&id=101",
|
|
191
209
|
"steps": [["wait",900],["fill","#mInviteGuild","/g/forge-crew"],["click","#mInvite [data-invite-guild]"],["wait",400]],
|
|
192
210
|
"expect": { "count": [["#mInviteList li", 4]], "value": [["#mInviteGuild", ""]],
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"in-mine-engagements": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["focus","#engageList .engageRow:first-child .btn"],["wait",200]], "expect": { "visible": ["#mineInvites", "#invitesList", "#engageList"], "count": [["#engageList .engageRow", 2], ["#engageList .engageRow .mineJoinNote:not([hidden])", 0]], "text": [["#engageList .engageRow:nth-child(1) .mineRow__name", "Forge Crew is applying to Tidepool — apply with them?"], ["#engageList .engageRow:nth-child(1) .mineRow__host", "tidepool.cloudbongos.com"], ["#engageList .engageRow:nth-child(2) .mineRow__name", "Lantern Works invited Forge Crew"], ["#engageList .engageRow:nth-child(1) .mineRow__hint", "You apply as yourself — add a note for the owner if you like."], ["#engageList .engageRow:nth-child(1) .btn", "Apply"]] } },
|
|
31
31
|
"in-mine-engagement-note": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["click","#engageList .engageRow:first-child .linklike"],["fill","#engageList .engageRow:first-child .mineJoinNote","I like the boring parts too: migrations and the deploy path."],["wait",150]], "expect": { "visible": ["#engageList .engageRow:first-child .mineJoinNote"], "hidden": ["#engageList .engageRow:first-child .linklike"], "value": [["#engageList .engageRow:first-child .mineJoinNote", "I like the boring parts too: migrations and the deploy path."]] } },
|
|
32
32
|
"in-mine-engagement-applied": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["click","#engageList .engageRow:first-child .linklike"],["fill","#engageList .engageRow:first-child .mineJoinNote","I like the boring parts too: migrations and the deploy path."],["click","#engageList .engageRow:first-child .btn"],["wait",400]], "expect": { "hidden": ["#engageList .engageRow:first-child .mineJoinNote", "#engageList .engageRow:first-child .linklike"], "hasClass": [["#engageList .engageRow:first-child .projMsg", "ok"], ["#engageList .engageRow:first-child .btn", "ghost"]], "text": [["#engageList .engageRow:first-child .btn", "Applied"], ["#engageList .engageRow:first-child .projMsg", "Requested access. The project’s owner admits new builders; once approved, sign in to enter."], ["#engageList .engageRow:nth-child(2) .btn", "Apply"]] } },
|
|
33
|
+
"in-mine-guild-cards": {"auth": true, "url": "/projects?view=mine", "actions": [["wait", 900], ["focus", "#guildInviteList .guildRow:first-child .btn"], ["wait", 200]], "expect": {"visible": ["#mineInvites", "#guildInviteList", "#guildRequestList", "#guildInviteList .guildRow:first-child .guildCount"], "count": [["#guildInviteList .guildRow", 2], ["#guildRequestList .guildRow", 2], ["#guildInviteList .guildRow .guildCount", 2], ["#guildRequestList .guildRow .guildCount", 0]], "text": [["#guildInviteList .guildRow:nth-child(1) .mineRow__name", "You’re invited to join Open Hands"], ["#guildInviteList .guildRow:nth-child(2) .mineRow__name", "You’re invited to join Quiet Room"], ["#guildInviteList .guildRow:nth-child(1) .guildCount__what", "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."], ["#guildRequestList .guildRow:nth-child(1) .mineRow__name", "Tomás Ek asked to join Night Shift"], ["#guildRequestList .guildRow:nth-child(2) .mineRow__name", "@kai-lowe asked to join Night Shift"]], "checked": "#guildInviteList .guildRow:first-child input[value=\"on\"]"}},
|
|
34
|
+
"in-mine-guild-count-off": {"auth": true, "url": "/projects?view=mine", "actions": [["wait", 900], ["click", "#guildInviteList .guildRow:first-child .guildCount__opt:nth-child(2)"], ["wait", 150]], "expect": {"checked": "#guildInviteList .guildRow:first-child input[value=\"off\"]", "text": [["#guildInviteList .guildRow:first-child .guildCount__what", "Your numbers are left out of this guild’s totals, from its next weekly count."], ["#guildInviteList .guildRow:first-child .btn", "Accept"]]}},
|
|
35
|
+
"in-mine-guild-joined": {"auth": true, "url": "/projects?view=mine", "actions": [["wait", 900], ["click", "#guildInviteList .guildRow:first-child .guildCount__opt:nth-child(2)"], ["click", "#guildInviteList .guildRow:first-child .btn"], ["wait", 400]], "expect": {"hidden": ["#guildInviteList .guildRow:first-child .btn.ghost + .btn"], "hasClass": [["#guildInviteList .guildRow:first-child .projMsg", "ok"], ["#guildInviteList .guildRow:first-child .btn", "ghost"]], "text": [["#guildInviteList .guildRow:first-child .btn", "Joined"], ["#guildInviteList .guildRow:first-child .projMsg", "You’ve joined Open Hands, and your numbers are left out of its totals."]]}},
|
|
33
36
|
"in-manage-app-live": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStat","#mApp .appVars"],"hidden":[]} },
|
|
34
37
|
"in-manage-app-building": { "auth": true, "url": "/projects?view=manage&id=102", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStages"],"hidden":[]} },
|
|
35
38
|
"in-manage-app-failed": { "auth": true, "url": "/projects?view=manage&id=105", "actions": [["wait",900],["focus","#mApp .appRetryBtn"]], "expect": {"visible":["#mAppCard","#mApp p.err","#mApp .appRetryBtn"],"hidden":["#mApp .appRetry"]} },
|
|
@@ -51,6 +51,13 @@
|
|
|
51
51
|
{ "handle": "sol-reyes", "display_name": "Sol Reyes", "avatar_url": null, "membership_kind": "member" }
|
|
52
52
|
],
|
|
53
53
|
"count": 3
|
|
54
|
+
},
|
|
55
|
+
"requests": {
|
|
56
|
+
"incoming": [
|
|
57
|
+
{ "id": "601", "created_at": "2026-10-04T19:20:00.000Z", "seen_at": null, "handle": "tomas-ek", "display_name": "Tomás Ek", "avatar_url": null },
|
|
58
|
+
{ "id": "602", "created_at": "2026-10-02T11:05:00.000Z", "seen_at": null, "handle": "kai-lowe", "display_name": null, "avatar_url": null }
|
|
59
|
+
],
|
|
60
|
+
"outgoing": []
|
|
54
61
|
}
|
|
55
62
|
},
|
|
56
63
|
"open-hands": {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"incoming": [
|
|
3
|
+
{ "id": "501", "created_at": "2026-10-04T15:10:00.000Z", "seen_at": null, "slug": "open-hands", "name": "Open Hands", "description": "A newer guild for first-time builders. Ask anything.", "visibility": "public" },
|
|
4
|
+
{ "id": "502", "created_at": "2026-10-03T08:45:00.000Z", "seen_at": "2026-10-03T09:00:00.000Z", "slug": "quiet-room", "name": "Quiet Room", "description": "", "visibility": "unlisted" }
|
|
5
|
+
],
|
|
6
|
+
"outgoing": [],
|
|
7
|
+
"incoming_count": 2,
|
|
8
|
+
"outgoing_count": 0
|
|
9
|
+
}
|
|
@@ -512,6 +512,44 @@ function guildInvitePost(res, body) {
|
|
|
512
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
513
|
return json(res, 200, { invited: false, guild: { slug: g.public.guild.slug, name: g.public.guild.name }, invites, count: invites.length });
|
|
514
514
|
}
|
|
515
|
+
// The guild cards in the mine view's invitations (task 1004583): GET /me/guild-requests is
|
|
516
|
+
// the invites to the builder (me__guild-requests.json, each card with the guild's CURRENT
|
|
517
|
+
// visibility) plus `owner_incoming`, the join requests to every held guild they own (its
|
|
518
|
+
// `requests` in guilds.json), each naming its guild — one read, as the route answers it. The
|
|
519
|
+
// stub mirrors the routes' refusals: an invitee's accept must echo the card's visibility (400
|
|
520
|
+
// without one, 409 with the guild's current one when it differs), only `visibility` and
|
|
521
|
+
// `counted_in_totals` are taken, a row the caller may not answer is one 404, and a decline is
|
|
522
|
+
// a SILENT 204. Nothing is written: a reload draws the same cards, as the join relay's
|
|
523
|
+
// 'requested' does.
|
|
524
|
+
function ownedRequests() {
|
|
525
|
+
return Object.values(guildsHeld()).filter((g) => g.members && g.members.you.membership_kind === 'owner')
|
|
526
|
+
.flatMap((g) => ((g.requests && g.requests.incoming) || []).map((r) => ({ ...r, guild: { slug: g.members.guild.slug, name: g.members.guild.name } })));
|
|
527
|
+
}
|
|
528
|
+
function myGuildRequests(res) {
|
|
529
|
+
const q = heldFixture('me__guild-requests');
|
|
530
|
+
const owner = ownedRequests();
|
|
531
|
+
return json(res, 200, { ...q, owner_incoming: owner, owner_incoming_count: owner.length });
|
|
532
|
+
}
|
|
533
|
+
function guildRequestAnswer(res, id, verb, body) {
|
|
534
|
+
const notFound = () => refuse(res, 404, 'guild_request_not_found', 'guild_request_not_found');
|
|
535
|
+
if (!/^\d{1,19}$/.test(id)) return notFound();
|
|
536
|
+
const bad = Object.keys(body).find((k) => !['visibility', 'counted_in_totals'].includes(k));
|
|
537
|
+
if (bad) return refuse(res, 400, 'validation_failed', `unknown field: ${bad}`);
|
|
538
|
+
if (body.visibility !== undefined && !['public', 'unlisted'].includes(body.visibility)) return refuse(res, 400, 'validation_failed', 'visibility must be public or unlisted');
|
|
539
|
+
if (body.counted_in_totals !== undefined && typeof body.counted_in_totals !== 'boolean') return refuse(res, 400, 'validation_failed', 'counted_in_totals must be a boolean');
|
|
540
|
+
const invite = (heldFixture('me__guild-requests').incoming || []).find((r) => r.id === id);
|
|
541
|
+
const request = Object.values(guildsHeld()).some((g) => g.members && g.members.you.membership_kind === 'owner'
|
|
542
|
+
&& ((g.requests && g.requests.incoming) || []).some((r) => r.id === id));
|
|
543
|
+
if (!invite && !request) return notFound();
|
|
544
|
+
if (verb === 'decline') { res.writeHead(204, { 'cache-control': 'no-store' }); return res.end(); }
|
|
545
|
+
if (invite) {
|
|
546
|
+
if (!body.visibility) return refuse(res, 400, 'guild_card_visibility_required', 'send the visibility your invite card showed');
|
|
547
|
+
const held = guildsHeld()[invite.slug];
|
|
548
|
+
const now = (held && held.members && held.members.guild.visibility) || invite.visibility;
|
|
549
|
+
if (body.visibility !== now) return json(res, 409, { error: { code: 'guild_visibility_changed', message: 'guild_visibility_changed', details: { visibility: now } } });
|
|
550
|
+
}
|
|
551
|
+
return json(res, 200, { status: 'accepted' });
|
|
552
|
+
}
|
|
515
553
|
function guildEngagementLive(slug, origin) {
|
|
516
554
|
const g = typeof slug === 'string' && guildsHeld()[slug.toLowerCase()];
|
|
517
555
|
if (!g || !g.members) return false;
|
|
@@ -540,6 +578,9 @@ const server = http.createServer((req, res) => {
|
|
|
540
578
|
if (guild && guild[2] === '/members' && req.method === 'GET') return AUTH ? guildRead(res, guild[1], 'members', origin) : json(res, 401, UNAUTH);
|
|
541
579
|
if (guild && guild[2] === '/membership' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => guildMembershipPatch(res, guild[1], body)) : json(res, 401, UNAUTH);
|
|
542
580
|
if (guild && guild[2] === '/engagements' && req.method === 'POST') return AUTH ? readBody(req, (body) => guildEngagementPost(res, guild[1], body)) : json(res, 401, UNAUTH);
|
|
581
|
+
if (rest === '/me/guild-requests' && req.method === 'GET') return AUTH ? myGuildRequests(res) : json(res, 401, UNAUTH);
|
|
582
|
+
const answer = rest.match(/^\/guild-requests\/([^/]+)\/(accept|decline)$/);
|
|
583
|
+
if (answer && req.method === 'POST') return AUTH ? readBody(req, (body) => guildRequestAnswer(res, answer[1], answer[2], body)) : json(res, 401, UNAUTH);
|
|
543
584
|
if (rest === '/provisioning/instances' && req.method === 'GET') return AUTH ? instances(res, null, false) : json(res, 401, UNAUTH);
|
|
544
585
|
// the planet-physics questions' pre-create check (task 1004418): nothing stops here —
|
|
545
586
|
// the kit carries no screen. Of the soft-limit table it mirrors only the three hide
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.64",
|
|
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.64",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
|
@@ -772,9 +772,9 @@
|
|
|
772
772
|
"license": "MIT"
|
|
773
773
|
},
|
|
774
774
|
"node_modules/proxy-addr": {
|
|
775
|
-
"version": "2.0.
|
|
776
|
-
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.
|
|
777
|
-
"integrity": "sha512-
|
|
775
|
+
"version": "2.0.8",
|
|
776
|
+
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
|
777
|
+
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
|
778
778
|
"license": "MIT",
|
|
779
779
|
"dependencies": {
|
|
780
780
|
"forwarded": "0.2.0",
|
|
@@ -782,6 +782,10 @@
|
|
|
782
782
|
},
|
|
783
783
|
"engines": {
|
|
784
784
|
"node": ">= 0.10"
|
|
785
|
+
},
|
|
786
|
+
"funding": {
|
|
787
|
+
"type": "opencollective",
|
|
788
|
+
"url": "https://opencollective.com/express"
|
|
785
789
|
}
|
|
786
790
|
},
|
|
787
791
|
"node_modules/qs": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.64",
|
|
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
|
@@ -9148,5 +9148,17 @@
|
|
|
9148
9148
|
"id": "1004584",
|
|
9149
9149
|
"text": "Shipping finished tasks now works for projects that install Bongos as a package, so their work lands instead of getting stuck halfway."
|
|
9150
9150
|
}
|
|
9151
|
+
],
|
|
9152
|
+
"1.21.63": [
|
|
9153
|
+
{
|
|
9154
|
+
"id": "1004583",
|
|
9155
|
+
"text": "Builders can now accept or decline a guild invite from their projects page, choosing first whether their numbers count toward the guild's totals, and guild owners can answer join requests there too."
|
|
9156
|
+
}
|
|
9157
|
+
],
|
|
9158
|
+
"1.21.64": [
|
|
9159
|
+
{
|
|
9160
|
+
"id": "1002304",
|
|
9161
|
+
"text": "Owners now see people who applied with a guild grouped under that guild's name in the applications queue, with one \"Invite all\" button that still admits each person individually."
|
|
9162
|
+
}
|
|
9151
9163
|
]
|
|
9152
9164
|
}
|
|
@@ -534,7 +534,29 @@ async function safeSyncRankRole(builder) {
|
|
|
534
534
|
// null this function already promises on every other failure.
|
|
535
535
|
const APPLICANT_PROFILE_TIMEOUT_MS = 5000;
|
|
536
536
|
|
|
537
|
-
|
|
537
|
+
// The guild an applicant applied with, as the hub labels it beside their profile
|
|
538
|
+
// (ADR 0365 D5, task 1002304): { slug, name }, or null. Cross-instance input, so
|
|
539
|
+
// it is re-bounded here rather than trusted — the hub's own caps mirrored (a guild
|
|
540
|
+
// name is at most 80 characters, a slug is the handle charset). A slug that does
|
|
541
|
+
// not fit is dropped to null, which the queue already reads as "no public page";
|
|
542
|
+
// a name that does not fit drops the whole label, and the row simply shows no
|
|
543
|
+
// guild. An OLDER hub sends no `guild` at all, which is the same null.
|
|
544
|
+
const GUILD_LABEL_NAME_MAX = 80;
|
|
545
|
+
const GUILD_LABEL_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
|
|
546
|
+
|
|
547
|
+
function applicantGuildLabel(raw) {
|
|
548
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
549
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
550
|
+
if (!name || name.length > GUILD_LABEL_NAME_MAX) return null;
|
|
551
|
+
const slug = typeof raw.slug === 'string' && GUILD_LABEL_SLUG_RE.test(raw.slug) ? raw.slug : null;
|
|
552
|
+
return { slug, name };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// readApplicantViewFromHub(githubId) → { profile, guild } | null — the same live
|
|
556
|
+
// read, with the guild label the hub sends beside the profile (ADR 0365 D5). The
|
|
557
|
+
// label rides ONLY beside a profile, here as on the hub: an applicant who
|
|
558
|
+
// discloses nothing discloses no guild either, so a null profile is a null view.
|
|
559
|
+
async function readApplicantViewFromHub(githubId) {
|
|
538
560
|
const idp = loadIdpConfig();
|
|
539
561
|
if (!idp || githubId == null) return null;
|
|
540
562
|
try {
|
|
@@ -550,13 +572,21 @@ async function readApplicantProfileFromHub(githubId) {
|
|
|
550
572
|
});
|
|
551
573
|
if (!r.ok) return null;
|
|
552
574
|
const body = await r.json().catch(() => null);
|
|
553
|
-
|
|
575
|
+
const profile = (body && body.profile) || null;
|
|
576
|
+
return profile ? { profile, guild: applicantGuildLabel(body.guild) } : null;
|
|
554
577
|
} catch (err) {
|
|
555
578
|
log.info('[auth] hub applicant-profile read failed (non-blocking):', err.message);
|
|
556
579
|
return null;
|
|
557
580
|
}
|
|
558
581
|
}
|
|
559
582
|
|
|
583
|
+
// The profile half alone — the doorway port this read first shipped as, kept so
|
|
584
|
+
// a module on it reads exactly what it always did.
|
|
585
|
+
async function readApplicantProfileFromHub(githubId) {
|
|
586
|
+
const view = await readApplicantViewFromHub(githubId);
|
|
587
|
+
return view ? view.profile : null;
|
|
588
|
+
}
|
|
589
|
+
|
|
560
590
|
// notifyHubOfInvite(githubLogin, { invited }) — tell the hub about THIS project's own
|
|
561
591
|
// invite, so the invitee sees it in My Projects (task 1002818, ADR 0353). `invited:
|
|
562
592
|
// true` after the invite route leaves the login holding an invited row; `false` after
|
|
@@ -601,6 +631,7 @@ module.exports = {
|
|
|
601
631
|
firstTimeAdmissionBlocked,
|
|
602
632
|
instanceNeedsFirstAdmin,
|
|
603
633
|
readApplicantProfileFromHub,
|
|
634
|
+
readApplicantViewFromHub,
|
|
604
635
|
// Re-exported, not defined here (task 1003579 moved it to project-door.js with
|
|
605
636
|
// the two knobs it composes against). auth.js, the doorway's api.joinabilityMode
|
|
606
637
|
// and four test files import it by this path, and the read-back it answers —
|
package/src/bongos/auth.js
CHANGED
|
@@ -55,7 +55,7 @@ const ic = require('../instance-config');
|
|
|
55
55
|
const { branding, userAgent } = require('../branding');
|
|
56
56
|
|
|
57
57
|
const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
|
|
58
|
-
const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readApplicantProfileFromHub, webAdmissionStatus } = require('./auth-admission.js');
|
|
58
|
+
const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readApplicantProfileFromHub, readApplicantViewFromHub, webAdmissionStatus } = require('./auth-admission.js');
|
|
59
59
|
const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
|
|
60
60
|
const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, readApplicationVouch, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
|
|
61
61
|
const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
|
|
@@ -828,6 +828,7 @@ module.exports = {
|
|
|
828
828
|
openEnrollmentEnabled,
|
|
829
829
|
joinabilityMode,
|
|
830
830
|
readApplicantProfileFromHub,
|
|
831
|
+
readApplicantViewFromHub,
|
|
831
832
|
notifyHubOfInvite,
|
|
832
833
|
readApplicationVouch,
|
|
833
834
|
firstTimeAdmissionBlocked,
|
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.64'; // 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');
|
|
@@ -362,6 +362,12 @@ module.exports = {
|
|
|
362
362
|
// re-address it. Fails soft to null on every error, so a hub outage costs
|
|
363
363
|
// the profile block and never the queue.
|
|
364
364
|
get readApplicantProfileFromHub() { return require('./bongos/auth').readApplicantProfileFromHub; },
|
|
365
|
+
// readApplicantViewFromHub(githubId) — the same read and the same narrow port,
|
|
366
|
+
// answering { profile, guild } | null: the profile above plus the guild the
|
|
367
|
+
// applicant applied with, { slug, name } or null (ADR 0365 D5, task
|
|
368
|
+
// 1002304). The guild rides only beside a profile, so a null profile is a
|
|
369
|
+
// null view; an older hub that sends no guild answers guild: null.
|
|
370
|
+
get readApplicantViewFromHub() { return require('./bongos/auth').readApplicantViewFromHub; },
|
|
365
371
|
// notifyHubOfInvite(githubLogin, { invited }) — tell the hub about one of THIS
|
|
366
372
|
// project's invites (true) or its rescind (false), so the invitee sees it in
|
|
367
373
|
// My Projects (task 1002818, ADR 0353). The same narrow-port reason as the
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// tests/applicant_view_guild.mjs — the guild label reaches the project (task
|
|
2
|
+
// 1002304, R28 of goal 1000110; ADR 0365 D5).
|
|
3
|
+
//
|
|
4
|
+
// THE GAP. The hub's POST /sso/applicant-profile answers { profile, guild } since
|
|
5
|
+
// task 1002303, and core kept only `body.profile` — so the reviewer queue could
|
|
6
|
+
// never know which applicants came as a guild. readApplicantViewFromHub is the
|
|
7
|
+
// same live read answering both halves, and readApplicantProfileFromHub is kept
|
|
8
|
+
// for any module on the older port.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IS PINNED. The label is cross-instance input, so it is re-bounded here
|
|
11
|
+
// (the hub's own caps mirrored) rather than trusted; it rides only beside a
|
|
12
|
+
// profile, as it does on the hub; and an older hub that sends no guild is a
|
|
13
|
+
// guild of null, never an error.
|
|
14
|
+
//
|
|
15
|
+
// DB-free and network-free: auth-config is pinned in require.cache so the
|
|
16
|
+
// federation config resolves without env, and global fetch is a spy (the
|
|
17
|
+
// tests/applicant_profile_read_bounds.mjs idiom).
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test --test-reporter=tap tests/applicant_view_guild.mjs
|
|
20
|
+
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
const cfgPath = require.resolve('../src/bongos/auth-config.js');
|
|
28
|
+
const realCfg = require('../src/bongos/auth-config.js');
|
|
29
|
+
require.cache[cfgPath] = {
|
|
30
|
+
id: cfgPath,
|
|
31
|
+
filename: cfgPath,
|
|
32
|
+
loaded: true,
|
|
33
|
+
exports: {
|
|
34
|
+
...realCfg,
|
|
35
|
+
loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'cid', clientSecret: 'sec' }),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const admission = require('../src/bongos/auth-admission.js');
|
|
40
|
+
const api = require('../src/module-api.js');
|
|
41
|
+
|
|
42
|
+
const PROFILE = { view: 'sliver', sliver: { handle: 'bo' } };
|
|
43
|
+
|
|
44
|
+
// The hub answers `body` for every call; the result is what the read returns.
|
|
45
|
+
async function viewFor(body) {
|
|
46
|
+
const real = globalThis.fetch;
|
|
47
|
+
globalThis.fetch = async () => ({ ok: true, json: async () => body });
|
|
48
|
+
try { return await admission.readApplicantViewFromHub(42); } finally { globalThis.fetch = real; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('the guild the hub names rides beside the profile', async () => {
|
|
52
|
+
const view = await viewFor({ profile: PROFILE, guild: { slug: 'lantern-works', name: 'Lantern Works' } });
|
|
53
|
+
assert.deepEqual(view, { profile: PROFILE, guild: { slug: 'lantern-works', name: 'Lantern Works' } });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an unlisted guild keeps its name and its null slug', async () => {
|
|
57
|
+
const view = await viewFor({ profile: PROFILE, guild: { slug: null, name: 'Night Shift' } });
|
|
58
|
+
assert.deepEqual(view.guild, { slug: null, name: 'Night Shift' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an OLDER hub that sends no guild is guild null, and the profile is untouched', async () => {
|
|
62
|
+
const view = await viewFor({ profile: PROFILE });
|
|
63
|
+
assert.deepEqual(view, { profile: PROFILE, guild: null });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('no profile, no view — a guild never rides alone (the hub’s own rule, restated here)', async () => {
|
|
67
|
+
assert.equal(await viewFor({ profile: null, guild: { slug: 'lantern-works', name: 'Lantern Works' } }), null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a label outside the hub’s own caps is not trusted', async () => {
|
|
71
|
+
const name81 = 'x'.repeat(81);
|
|
72
|
+
assert.equal((await viewFor({ profile: PROFILE, guild: { slug: 'ok', name: name81 } })).guild, null, 'a name over 80 characters drops the label');
|
|
73
|
+
assert.equal((await viewFor({ profile: PROFILE, guild: { slug: 'ok', name: ' ' } })).guild, null, 'a blank name is no name');
|
|
74
|
+
assert.equal((await viewFor({ profile: PROFILE, guild: { slug: 'ok', name: 7 } })).guild, null, 'a name must be text');
|
|
75
|
+
assert.equal((await viewFor({ profile: PROFILE, guild: 'Lantern Works' })).guild, null, 'a label must be an object');
|
|
76
|
+
for (const slug of ['Lantern Works', '-lead', 'trail-', '<script>', 'x'.repeat(40), 42]) {
|
|
77
|
+
assert.deepEqual((await viewFor({ profile: PROFILE, guild: { slug, name: 'Lantern' } })).guild, { slug: null, name: 'Lantern' },
|
|
78
|
+
`a slug outside the handle charset (${JSON.stringify(slug)}) is no address — the name still labels the row`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the older port answers the profile alone, exactly as it always did', async () => {
|
|
83
|
+
const real = globalThis.fetch;
|
|
84
|
+
globalThis.fetch = async () => ({ ok: true, json: async () => ({ profile: PROFILE, guild: { slug: 'a1', name: 'A' } }) });
|
|
85
|
+
try {
|
|
86
|
+
assert.deepEqual(await admission.readApplicantProfileFromHub(42), PROFILE);
|
|
87
|
+
} finally { globalThis.fetch = real; }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a failed hub read is a null view, like every other failure here', async () => {
|
|
91
|
+
const real = globalThis.fetch;
|
|
92
|
+
globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); };
|
|
93
|
+
try {
|
|
94
|
+
assert.equal(await admission.readApplicantViewFromHub(42), null);
|
|
95
|
+
} finally { globalThis.fetch = real; }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a module reaches it through the doorway, the same narrow port as the profile read', () => {
|
|
99
|
+
assert.equal(api.readApplicantViewFromHub, admission.readApplicantViewFromHub);
|
|
100
|
+
assert.equal(api.readApplicantProfileFromHub, admission.readApplicantProfileFromHub);
|
|
101
|
+
});
|
|
@@ -390,6 +390,10 @@ let actingRank = 'archon';
|
|
|
390
390
|
let actingId = 42;
|
|
391
391
|
// The hub notice port (task 1002818, ADR 0353), recorded instead of sent.
|
|
392
392
|
let notices = [];
|
|
393
|
+
// The hub's applicant view (task 1002304, ADR 0365 D5): github_id → what the hub
|
|
394
|
+
// answers, recorded so a test can see WHICH ids were asked about. Empty = no hub.
|
|
395
|
+
let hubViews = {};
|
|
396
|
+
let hubAsked = [];
|
|
393
397
|
|
|
394
398
|
require.cache[apiPath].exports = {
|
|
395
399
|
...realApi,
|
|
@@ -403,6 +407,7 @@ require.cache[apiPath].exports = {
|
|
|
403
407
|
readApplicationVouch: (token, { githubLogin } = {}) =>
|
|
404
408
|
applicationVouchIdentity(token, { githubLogin, publicKey: hubPublicKey, clientId: SELF_CLIENT_ID }),
|
|
405
409
|
notifyHubOfInvite: (login, opts) => { notices.push({ login, invited: opts && opts.invited }); },
|
|
410
|
+
readApplicantViewFromHub: async (githubId) => { hubAsked.push(githubId); return hubViews[githubId] || null; },
|
|
406
411
|
};
|
|
407
412
|
|
|
408
413
|
seams._reset();
|
|
@@ -447,7 +452,7 @@ function call(method, pathname, body, extraHeaders = {}) {
|
|
|
447
452
|
req.end(payload);
|
|
448
453
|
});
|
|
449
454
|
}
|
|
450
|
-
const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; notices = []; };
|
|
455
|
+
const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; notices = []; hubViews = {}; hubAsked = []; };
|
|
451
456
|
|
|
452
457
|
// ===========================================================================
|
|
453
458
|
// The interpreter is the instrument, so it is calibrated first
|
|
@@ -670,6 +675,38 @@ test('list-pending: the queue filters on status and NOTHING else', async () => {
|
|
|
670
675
|
'an extra predicate here would hide rows from the owner, and a placeholder-free one is invisible to the fake pool');
|
|
671
676
|
});
|
|
672
677
|
|
|
678
|
+
// The guild label (task 1002304, R28; ADR 0365 D5): the hub answers { profile,
|
|
679
|
+
// guild } and core used to keep only the profile. It rides the SAME vouch-keyed
|
|
680
|
+
// read, so it can reach a row only the way the profile can.
|
|
681
|
+
test('list-pending: a vouched applicant’s guild rides onto the row beside their profile', async () => {
|
|
682
|
+
reset();
|
|
683
|
+
const profile = { view: 'sliver', sliver: { handle: 'bo-h' } };
|
|
684
|
+
hubViews['9002'] = { profile, guild: { slug: 'lantern-works', name: 'Lantern Works' } };
|
|
685
|
+
const r = await call('GET', '/api/gds/access-requests');
|
|
686
|
+
const bo = r.json.requests.find((x) => x.github_login === 'bo');
|
|
687
|
+
assert.deepEqual(bo.profile, profile);
|
|
688
|
+
assert.deepEqual(bo.guild, { slug: 'lantern-works', name: 'Lantern Works' });
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test('list-pending: an unvouched row is never asked about, and carries guild null', async () => {
|
|
692
|
+
reset();
|
|
693
|
+
hubViews['9002'] = { profile: { view: 'sliver', sliver: {} }, guild: { slug: null, name: 'Night Shift' } };
|
|
694
|
+
const r = await call('GET', '/api/gds/access-requests');
|
|
695
|
+
const ada = r.json.requests.find((x) => x.github_login === 'ada');
|
|
696
|
+
assert.equal(ada.guild, null, 'a login anybody could have typed must not borrow a guild');
|
|
697
|
+
assert.equal(ada.profile, null);
|
|
698
|
+
assert.deepEqual(hubAsked, ['9002'], 'only the vouched row reached the hub');
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test('list-pending: a hub that answers nothing (or an older one) leaves every row as it was', async () => {
|
|
702
|
+
reset();
|
|
703
|
+
const r = await call('GET', '/api/gds/access-requests');
|
|
704
|
+
for (const row of r.json.requests) {
|
|
705
|
+
assert.equal(row.guild, null, `${row.github_login}: no view, no guild`);
|
|
706
|
+
assert.equal(row.profile, null);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
673
710
|
test('list-pending: ?status=invited|dismissed reads the audit trail, and a bad status is refused', async () => {
|
|
674
711
|
reset();
|
|
675
712
|
const invited = await call('GET', '/api/gds/access-requests?status=invited');
|
package/tests/guilds_api.mjs
CHANGED
|
@@ -578,6 +578,37 @@ test('the owner\'s invite list names only invitees THIS owner could address (a t
|
|
|
578
578
|
'public, or connected to the viewing owner — the addressableAccount rule, reusing the one symmetric-edge fragment');
|
|
579
579
|
});
|
|
580
580
|
|
|
581
|
+
test('GET /me/guild-requests carries the requests to EVERY guild I own in one statement keyed on the session, each naming its guild (task 1004583)', async () => {
|
|
582
|
+
const OWNED = (t) => /WHERE r\.kind = 'request' AND r\.status = 'pending'/.test(t);
|
|
583
|
+
on(base((t) => (OWNED(t) && /^\s*SELECT/.test(t)
|
|
584
|
+
? result([{ id: '601', created_at: 'c', seen_at: null, slug: 'night-shift', name: 'Night Shift', handle: 'tomas-ek', display_name: 'Tomás Ek', avatar_url: null },
|
|
585
|
+
{ id: '602', created_at: 'c', seen_at: 's', slug: 'lamp-room', name: 'Lamp Room', handle: 'kai-lowe', display_name: null, avatar_url: null }])
|
|
586
|
+
: null)));
|
|
587
|
+
const res = await call('GET', '/api/bongos/me/guild-requests');
|
|
588
|
+
assert.equal(res.status, 200);
|
|
589
|
+
assert.equal(res.headers['cache-control'], 'no-store');
|
|
590
|
+
assert.deepEqual(res.json.owner_incoming[0], { id: '601', created_at: 'c', seen_at: null, handle: 'tomas-ek', display_name: 'Tomás Ek', avatar_url: null, guild: { slug: 'night-shift', name: 'Night Shift' } });
|
|
591
|
+
assert.equal(res.json.owner_incoming_count, res.json.owner_incoming.length);
|
|
592
|
+
assert.equal(res.json.incoming_count, res.json.incoming.length, 'the invite half is unchanged');
|
|
593
|
+
|
|
594
|
+
const reads = calls.filter((c) => OWNED(c.text) && /^\s*SELECT/.test(c.text));
|
|
595
|
+
assert.equal(reads.length, 1, 'one statement for every guild I own — never one per guild');
|
|
596
|
+
const [read] = reads;
|
|
597
|
+
assert.equal(read.params[0], ME, 'keyed on the SESSION’s github_id');
|
|
598
|
+
assert.equal(read.params[2], guilds.LIST_MAX, 'capped as every queue is');
|
|
599
|
+
assert.ok(read.text.includes(require('../modules/platform-identity/account-visibility.js').accountActiveSql('pa')),
|
|
600
|
+
'a requester floors on an active account, as the per-guild queue does');
|
|
601
|
+
assert.match(read.text, /o\.guild_id = r\.guild_id AND o\.github_id = \$1::bigint\s+AND o\.membership_kind = 'owner'/, 'the owner test, per row, in the statement');
|
|
602
|
+
assert.match(read.text, /JOIN platform_identity_accounts pa ON pa\.github_id = r\.github_id/);
|
|
603
|
+
assert.doesNotMatch(read.text, /github_login|pa\.github_id AS|r\.github_id AS/, 'no GitHub identity leaves the module');
|
|
604
|
+
assert.match(read.text, /pa\.handle, pa\.display_name, pa\.avatar_url/, 'the person columns a guild list carries, no more');
|
|
605
|
+
|
|
606
|
+
const stamp = calls.find((c) => /^\s*UPDATE platform_identity_guild_requests r SET seen_at = now\(\)/.test(c.text));
|
|
607
|
+
assert.ok(stamp, 'the unseen request is stamped, as the per-guild read stamps it');
|
|
608
|
+
assert.deepEqual(stamp.params, [['601'], ME], 'only the unseen row, by the same owner');
|
|
609
|
+
assert.match(stamp.text, /r\.kind = 'request'[\s\S]*o\.github_id = \$2::bigint\s+AND o\.membership_kind = 'owner'/, 'the stamp re-asserts the owner test');
|
|
610
|
+
});
|
|
611
|
+
|
|
581
612
|
// ===========================================================================
|
|
582
613
|
// 7. Nothing here is authority; counts are lengths
|
|
583
614
|
// ===========================================================================
|
package/tests/guilds_db.mjs
CHANGED
|
@@ -160,6 +160,13 @@ try {
|
|
|
160
160
|
'the requester\'s own counted_in_totals choice rides the request — the owner cannot override it');
|
|
161
161
|
|
|
162
162
|
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'created', 'Bob asks');
|
|
163
|
+
const ownerQ = await guilds.listOwnedRequests({ githubId: OWNER }, { pool });
|
|
164
|
+
const bobAsk = ownerQ.find((r) => r.handle === 'r24bob');
|
|
165
|
+
ok(bobAsk && bobAsk.guild.slug === 'r24-pub' && !('github_id' in bobAsk) && !('kind' in bobAsk),
|
|
166
|
+
'the owner\'s ONE read across every guild they own carries Bob\'s request, naming its guild, by handle never github_id (task 1004583)');
|
|
167
|
+
ok((await guilds.listOwnedRequests({ githubId: ALICE }, { pool })).every((r) => r.guild.slug !== 'r24-pub'),
|
|
168
|
+
'a member who is not the owner reads none of its requests (the owner gate is in the statement)');
|
|
169
|
+
ok((await openRows('r24-pub', BOB))[0].seen_at !== null, 'reading it stamps seen_at on the request, as the per-guild queue does');
|
|
163
170
|
ok(await guilds.declineRequest({ id: await pendingId('r24-pub', BOB), githubId: OWNER }, { pool }), 'the owner declines');
|
|
164
171
|
const before = (await openRows('r24-pub', BOB)).length;
|
|
165
172
|
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'swallowed',
|
|
@@ -37,11 +37,13 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
|
37
37
|
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
38
38
|
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
39
39
|
|
|
40
|
-
// the rows region: mineEl/mineRow, the
|
|
40
|
+
// the rows region: mineEl/mineRow, the lists, the section, and the guild cards —
|
|
41
|
+
// through the guild invite and request cards (task 1004583), which loadInvites
|
|
42
|
+
// also reads; tests/hub_guild_cards.mjs drives those
|
|
41
43
|
const START = SCRIPT.indexOf(' function mineEl(tag, cls)');
|
|
42
|
-
const END = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function
|
|
44
|
+
const END = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function loadGuildQueue() {')) + 5;
|
|
43
45
|
assert.ok(START > 0, 'the region starts at mineEl');
|
|
44
|
-
assert.ok(END > START, 'and ends with
|
|
46
|
+
assert.ok(END > START, 'and ends with loadGuildQueue');
|
|
45
47
|
const REGION = SCRIPT.slice(START, END);
|
|
46
48
|
for (const fn of ['function mineRow(', 'function renderInvitesList(', 'function paintInvitesSection(', 'function loadInvites(',
|
|
47
49
|
'function engageLine(', 'function engageRow(', 'function renderEngagements(', 'function loadEngagements(']) {
|
|
@@ -81,7 +83,7 @@ const REQUESTED = { ok: true, status: 200, body: { ok: true, status: 'requested'
|
|
|
81
83
|
|
|
82
84
|
function boot({ answers = [] } = {}) {
|
|
83
85
|
const ids = new Map();
|
|
84
|
-
for (const id of ['mineInvites', 'invitesList', 'engageList', 'buildingList', 'buildingEmpty']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
|
|
86
|
+
for (const id of ['mineInvites', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList', 'buildingList', 'buildingEmpty']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
|
|
85
87
|
ids.get('mineInvites').hidden = true;
|
|
86
88
|
const requests = [];
|
|
87
89
|
const queued = answers.slice();
|
|
@@ -173,7 +175,8 @@ test('loadInvites reads both lists, and a guild read that fails is silent', asyn
|
|
|
173
175
|
] });
|
|
174
176
|
await ui.sandbox.loadInvites();
|
|
175
177
|
await ui.settle();
|
|
176
|
-
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(),
|
|
178
|
+
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(),
|
|
179
|
+
['GET /api/bongos/me/guild-engagements', 'GET /api/bongos/me/guild-requests', 'GET /api/bongos/my-projects/invites']);
|
|
177
180
|
for (const r of ui.requests) assert.equal(r.credentials, 'same-origin', 'own-scope reads, on the cookie session');
|
|
178
181
|
assert.equal(ui.cards().length, 0);
|
|
179
182
|
assert.equal(ui.section().hidden, true);
|
|
@@ -291,7 +294,7 @@ test('the cards are a real list inside the Invitations section, built with creat
|
|
|
291
294
|
assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/, 'no markup built from strings');
|
|
292
295
|
assert.match(SCRIPT, /if \(v === 'mine' && me\) \{ refreshList\(\); loadMemberships\(\); loadInvites\(\); \}/, 'the mine view re-reads both lists on entry, through loadInvites');
|
|
293
296
|
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
294
|
-
assert.match(COSMOS, /\.mineRows:not\(:empty\)
|
|
297
|
+
assert.match(COSMOS, /\.mineRows:not\(:empty\)~\.mineRows \.mineRow:first-child\{border-top:1px solid var\(--rule-soft\);\}/, 'one hairline between any two lists that have rows');
|
|
295
298
|
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>')), /engageRow/, 'styled in cosmos.css, not in the page head');
|
|
296
299
|
});
|
|
297
300
|
|