@bongos/core 1.21.63 → 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.
@@ -71,3 +71,18 @@
71
71
  /* A link inside a ledger cell (a session's tasks) is a control at a 13.5px line
72
72
  box: the padding buys the 24px floor and the negative margin hands it back. */
73
73
  .ledger td a:not(.lb-name) { display: inline-block; min-height: 24px; padding: 3px 0; margin: -3px 0; line-height: 18px; }
74
+
75
+ /* A guild's applicants under one head (task 1002304, ADR 0365 D5): the guild's
76
+ name, the count, and "Invite all" at the inline end. The head wraps at phone
77
+ width so the button drops under the name rather than overflowing, and centres
78
+ because a pill on the label's baseline sits low. */
79
+ .gate-guild .ov-group__head { flex-wrap: wrap; align-items: center; row-gap: 8px; }
80
+ .gate-guild .ov-group__head .btn-ghost { margin-left: auto; }
81
+ /* The name is a link to the guild's hub page when it has one — a control, so it
82
+ owes the 24px floor: the padding buys the box, the margin hands it back. */
83
+ a.gate-guild__link { display: inline-block; min-height: 24px; min-width: 24px; padding: 3px 0; margin: -3px 0; text-decoration: none; }
84
+ a.gate-guild__link:hover { color: var(--accent-ink); text-decoration: underline; text-underline-offset: 3px; }
85
+ /* The profile block's sentences ("Private account — …", "hides their record")
86
+ ride .ov-row__when, which never wraps; at 320px they pushed the page 54px
87
+ sideways. Inside the profile they are prose, so they wrap. */
88
+ .ov-row__profile .ov-row__when { white-space: normal; }
@@ -5,6 +5,9 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Watch — {{worldName}}</title>
7
7
  <meta name="description" content="Archon oversight for {{worldName}} — access requests, security, roster, reviews, and repo health.">
8
+ <!-- The hub's origin, for the link from a guild's head in the applications queue to
9
+ its page on the hub (task 1002304). Empty on a vanilla instance: no link. -->
10
+ <meta name="hub-origin" content="{{hubOrigin}}">
8
11
  <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
12
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
13
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -523,6 +523,90 @@
523
523
  );
524
524
  }
525
525
 
526
+ // ---- a guild's applicants, together (task 1002304, ADR 0365 D5) ----------
527
+ //
528
+ // An applicant who applied WITH their guild carries the hub's label for it,
529
+ // { slug, name } (slug null for an unlisted guild), and the queue gathers those
530
+ // rows under the guild's name so an owner reads a guild's application as one
531
+ // thing. "Invite all" is N ordinary invites — one PATCH per row, the same
532
+ // write the row's own button sends — because admission stays per person on the
533
+ // project (ADR 0365: N clicks, not one, is the honest cost; this only makes the
534
+ // N clicks one gesture). Nothing here is authority (ADR 0016): every row keeps
535
+ // its own pill, profile and buttons.
536
+ //
537
+ // The label counts only on a VOUCHED row. The server resolves it from the vouch
538
+ // alone, and an unvouched row wearing a guild would be the impersonation shape
539
+ // the vouch pill exists to expose. When no row carries a guild (an older hub, no
540
+ // hub, nobody applied with one) the list is exactly what it always was: one
541
+ // list, no group heads.
542
+ function guildOf(r) {
543
+ const g = r && r.vouched ? r.guild : null;
544
+ return g && typeof g.name === 'string' && g.name ? g : null;
545
+ }
546
+
547
+ // Groups in the order their newest applicant arrived; rows keep the server's
548
+ // order inside each. Two unlisted guilds that share a name share a head — the
549
+ // hub names an unlisted guild without an address, so the name is all there is.
550
+ function groupByGuild(requests) {
551
+ const groups = new Map();
552
+ const solo = [];
553
+ for (const r of requests) {
554
+ const g = guildOf(r);
555
+ if (!g) { solo.push(r); continue; }
556
+ const key = g.slug ? `slug:${g.slug}` : `name:${g.name}`;
557
+ if (!groups.has(key)) groups.set(key, { guild: g, rows: [] });
558
+ groups.get(key).rows.push(r);
559
+ }
560
+ return { groups: [...groups.values()], solo };
561
+ }
562
+
563
+ // The guild's own page on the hub when it has one. An unlisted guild has no
564
+ // slug and a vanilla instance has no hub; either way the name is plain text,
565
+ // never a link to nowhere.
566
+ function guildNameHtml(g) {
567
+ const hub = typeof window.OTB.hubOrigin === 'function' ? window.OTB.hubOrigin() : null;
568
+ const name = escapeHtml(g.name);
569
+ return hub && g.slug
570
+ ? `<a class="ov-group__name gate-guild__link" href="${escapeHtml(`${hub}/g/${encodeURIComponent(g.slug)}`)}">${name}</a>`
571
+ : `<span class="ov-group__name">${name}</span>`;
572
+ }
573
+
574
+ // "Invite all" covers the rows still waiting, and only two or more of them —
575
+ // a lone applicant already has their own Invite. The decided views offer no
576
+ // bulk move at all: a mass rescind is not a gesture this queue makes.
577
+ function bulkInviteHtml(rows) {
578
+ if (gateView !== 'pending') return '';
579
+ const ids = rows.filter((r) => r.status === 'pending').map((r) => r.id);
580
+ if (ids.length < 2) return '';
581
+ return ghost(`data-gate-bulk="invited" data-ids="${escapeHtml(ids.join(','))}"`, `Invite all ${ids.length}`);
582
+ }
583
+
584
+ function gateListHtml(requests) {
585
+ const list = (rows) => '<ul class="ov-rows">' + rows.map(gateRowHtml).join('') + '</ul>';
586
+ const { groups, solo } = groupByGuild(requests);
587
+ if (!groups.length) return list(solo);
588
+ const head = (name, n, extra) => `<div class="ov-group__head">${name}<span class="ov-group__n">${n}</span>${extra}</div>`;
589
+ return groups.map(({ guild, rows }) => (
590
+ `<section class="ov-group gate-guild">${head(guildNameHtml(guild), rows.length, bulkInviteHtml(rows))}${list(rows)}</section>`
591
+ )).join('') + (solo.length
592
+ ? `<section class="ov-group">${head('<span class="ov-group__name">On their own</span>', solo.length, '')}${list(solo)}</section>`
593
+ : '');
594
+ }
595
+
596
+ // Each invite settles on its own, so one refusal cannot strand the rest; the
597
+ // toast says how many landed, and the re-read shows exactly which.
598
+ async function inviteAll(ids) {
599
+ const settled = await Promise.allSettled(ids.map((id) =>
600
+ patchJson(`/access-requests/${encodeURIComponent(id)}`, { status: 'invited' })));
601
+ const failed = settled.filter((s) => s.status === 'rejected').map((s) => s.reason);
602
+ if (failed.some((e) => e && e.kind === '401')) throw failed.find((e) => e && e.kind === '401');
603
+ if (failed.length === ids.length) throw failed[0];
604
+ toast(failed.length
605
+ ? `Invited ${ids.length - failed.length} of ${ids.length} — ${failed[0].message || failed[0]}`
606
+ : `Invited all ${ids.length}.`);
607
+ await loadGate();
608
+ }
609
+
526
610
  function renderGate(data, target) {
527
611
  const requests = Array.isArray(data.requests) ? data.requests : [];
528
612
  // The attention tile counts WAITING work, never whatever list is on screen:
@@ -531,10 +615,12 @@
531
615
  if (gateView === 'pending') setAttention('att-access', requests.length);
532
616
  const head = inviteFormHtml() + '<div id="gate-view"></div>';
533
617
  target.innerHTML = requests.length
534
- ? head + '<ul class="ov-rows">' + requests.map(gateRowHtml).join('') + '</ul>'
618
+ ? head + gateListHtml(requests)
535
619
  : head + emptyStateHtml(gateEmptyMsg());
536
620
 
537
621
  if (requests.length) {
622
+ wireActions(target, '[data-gate-bulk]', (btn) =>
623
+ inviteAll(String(btn.getAttribute('data-ids') || '').split(',').filter(Boolean)), 'Could not invite');
538
624
  wireActions(target, '[data-gate-action]', async (btn) => {
539
625
  const id = btn.getAttribute('data-id');
540
626
  const status = btn.getAttribute('data-gate-action');
@@ -16,4 +16,10 @@ Also gone, and the one rule in that list: a sealed state is a `.scroll` panel wh
16
16
  - **The three lifecycle states sit behind one `makeFilterBar` switch**, so the decided history is readable and the RESCIND (`invited → dismissed`) the route has always taken is finally offered — ADR 0208 recorded that nothing on this page did. A declined row offers no button: reviving one is the invite form above, which is what the server's own 409 names. The switch is rebuilt each render (the same lifecycle `wireInviteForm` lives with, since `section` replaces the whole body).
17
17
  - **The `att-access` tile counts pending only.** Its sub line says "pending", so the count is guarded on the pending view — reading the history must not repaint the board with a number that means something else.
18
18
 
19
+ **The queue gathers a guild's applicants under the guild's name (task 1002304, R28; ADR 0365 D5).** Rows carry the hub's `guild` label beside `profile`, and `gateListHtml` groups the VOUCHED rows that have one into `.ov-group` sections — the guild's name (a link to `<hub>/g/<slug>` when the page carries a hub origin and the guild is public), the count, and, on the pending view with two or more waiting, an "Invite all N" that sends N ordinary `PATCH {status:'invited'}`s and re-reads once. Three rules it keeps:
20
+
21
+ - **No guild anywhere means no group heads at all** — an older hub, no hub, or nobody applying with a guild renders the one list it always did. The rest sit under "On their own" only when at least one guild group exists.
22
+ - **An unvouched row is never filed under a guild**, even if it carries one: the server resolves the label from the vouch alone, and a guild head over an unvouched name would be the impersonation shape the pill exists to expose.
23
+ - **Admission stays per person.** Every grouped row keeps its own pill, profile and buttons; "Invite all" is N clicks made one gesture, never a new write, and the decided views offer no bulk move. `tests/watch_applications_queue.mjs` drives all of it; the harness fixture `access-requests.json` carries a three-member public guild and a one-member unlisted one.
24
+
19
25
  Also gone: harbor's 280px card deck with a 4px coloured "roof" band per box and gate's crate with a verdict band (state is a `.fact-pill`; the fleet reads in three grouped runs — running, parked or in transition, no box); government's bordered `.gov-section` boxes and the one `.gov-assign-row` five unrelated lists shared (each renders as the row it is); watch's third copy of the stat tile, its `table.watch`, its `.watch-btn`; three bare `<p>` sealed states (each is a `.scroll` panel whose lede is written from `OTB.rankLabel('metic')`, so the floor cannot drift from ADR 0157). The harness gained fixtures for `/boxes`, `/gate-approvals` and five session rows, because a page that only ever renders its error state cannot be redesigned by looking.
@@ -139,6 +139,12 @@ const MAX_LIVE_PROFILE_READS = 25;
139
139
  //
140
140
  // Each read fails soft to null independently (the doorway port swallows), so one
141
141
  // slow or missing account cannot fail the others or the listing.
142
+ //
143
+ // THE GUILD LABEL RIDES THE SAME READ (ADR 0365 D5, task 1002304): `guild` is
144
+ // { slug, name } when the applicant applied with their guild, else null — so it
145
+ // is keyed on the vouch and budgeted by the cap exactly as the profile is, and
146
+ // the queue groups a guild's applicants by it. An older hub sends no guild, and
147
+ // every row then carries `guild: null` and renders as it always did.
142
148
  async function attachApplicantProfiles(rows) {
143
149
  // The budget is spent UP FRONT, by index, rather than by decrementing a shared
144
150
  // counter inside the concurrent callbacks. Both are correct today — `.map`
@@ -151,10 +157,10 @@ async function attachApplicantProfiles(rows) {
151
157
  rows.forEach((r, i) => {
152
158
  if (r.applicant_github_id && resolvable.size < MAX_LIVE_PROFILE_READS) resolvable.add(i);
153
159
  });
154
- return Promise.all(rows.map(async (r, i) => ({
155
- ...r,
156
- profile: resolvable.has(i) ? await api.readApplicantProfileFromHub(r.applicant_github_id) : null,
157
- })));
160
+ return Promise.all(rows.map(async (r, i) => {
161
+ const view = resolvable.has(i) ? await api.readApplicantViewFromHub(r.applicant_github_id) : null;
162
+ return { ...r, profile: view ? view.profile : null, guild: view ? view.guild : null };
163
+ }));
158
164
  }
159
165
 
160
166
  // THE HUB'S NOTICE OF THIS PROJECT'S INVITES (task 1002818, ADR 0353). On a hosted
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.63",
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.63",
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.7",
776
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
777
- "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
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.63",
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",
@@ -9154,5 +9154,11 @@
9154
9154
  "id": "1004583",
9155
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
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
+ }
9157
9163
  ]
9158
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
- async function readApplicantProfileFromHub(githubId) {
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
- return (body && body.profile) || null;
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 —
@@ -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.63'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');
@@ -48,6 +48,7 @@ const PUBLISHED_SURFACE = [
48
48
  'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
49
49
  'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
50
50
  'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
51
+ 'readApplicantViewFromHub', // added by task 1002304 (ADR 0365 D5): the same NARROW hub read as readApplicantProfileFromHub, answering { profile, guild } so the reviewer queue can group a guild's applicants. The guild rides only beside a profile, and the older port is kept so a module on it reads exactly what it did
51
52
  'notifyHubOfInvite', // added by task 1002818 (ADR 0353): tell the hub about one of THIS project's invites (or its rescind) so the invitee sees it in My Projects. The same NARROW-port reason as readApplicantProfileFromHub: the call carries this instance's hub client secret, so a module passes one login and one direction and can neither widen nor re-address it
52
53
  'inviteExpiry', // added by task 1002969 (ADR 0356): the ONE read-time definition of when a recruit invite's door closes. The sign-in gate reads it in core; the onboarding queues and the hub's notice list are modules, so without the doorway each would restate the 30 days and the predicate, and two copies can close a door on different days
53
54
  'readApplicationVouch', // added by task 1002285 (ADR 0201 D2): the public access-request write's check of the hub's application vouch. NARROW for the same reason — core supplies the hub key and this instance's client_id, so a module passes a token and a login and can neither swap the key nor re-address the audience