@bongos/core 1.23.11 → 1.23.13

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.
@@ -16,6 +16,11 @@
16
16
  their own. The one viewer-relative page is the recruiter list: drawn only from a 200 of
17
17
  GET /scouting, whose door (a project owner, or project.curate) is the server's, never
18
18
  this page's (scouting-authz.js). A 401 or 403 draws no row.
19
+ THE RECRUIT DOOR (task 1002292; spec D6, owner 2026-10-27): a project OWNER (GET
20
+ /my-projects `owned`) gets a Recruit link on each leaderboard and search row. The hub
21
+ cannot read a project's project.recruit, which lives on that project (ADR 0335 D1), so
22
+ ownership stands in. The link only opens the project's own hall invite box with the
23
+ login filled in (ADR 0353); the project decides. The rows themselves never change.
19
24
  Hub-only, like /account: platform-server.js serves /community only where
20
25
  platform-identity is on. noindex until the SEO goal decides (owner, 2026-10-27).
21
26
  Server-stamped like the footer doors: {{…}} tokens filled by landingPage(). -->
@@ -69,19 +74,29 @@
69
74
  .connAv{width:36px;height:36px;border-radius:50%;flex:none;object-fit:cover;background:color-mix(in srgb,var(--ink) 12%,transparent);}
70
75
  .commRow .mineRow__name,.connWho .mineRow__name{overflow-wrap:anywhere;}
71
76
  /* the two dropdowns (the window, the search's craft): the field's pill */
72
- .commWhen select,.commFind select{flex:0 1 auto;min-height:44px;padding:0 14px;font:inherit;font-size:14.5px;color:var(--ink);
77
+ .commWhen select,.commFind select,.commFor select{flex:0 1 auto;min-height:44px;padding:0 14px;font:inherit;font-size:14.5px;color:var(--ink);
73
78
  background:var(--bg-card);border:1px solid var(--ctl-line);border-radius:var(--radius-pill);cursor:pointer;}
74
- .commWhen select:focus-visible,.commFind input:focus-visible,.commFind select:focus-visible{outline:2px solid var(--focus);outline-offset:3px;box-shadow:0 0 0 8px var(--bg);}
79
+ .commWhen select:focus-visible,.commFind input:focus-visible,.commFind select:focus-visible,.commFor select:focus-visible{outline:2px solid var(--focus);outline-offset:3px;box-shadow:0 0 0 8px var(--bg);}
75
80
  #findStatus{margin-top:8px;}
81
+ /* the Recruit door, a project owner's only (task 1002292): what it opens, or which
82
+ project it recruits to; then a fifth column after the credits, where your own row
83
+ keeps an invisible twin so every row's credits line up */
84
+ .commFor{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin:6px 0 4px;max-width:62ch;font-size:13.5px;line-height:1.5;color:var(--ink-soft);}
85
+ #findFor{margin-top:12px;}
86
+ .commRows.canRecruit .commRow{grid-template-columns:3ch 36px minmax(0,1fr) auto auto;}
87
+ .commGap{visibility:hidden;}
76
88
  /* the recruiter list, signed out: the account page's door */
77
89
  .commDoor{padding:8px 0 10px;max-width:62ch;}
78
90
  .commDoor p{margin:0 0 18px;font-size:15px;line-height:1.55;color:var(--ink-soft);}
79
91
  .commDoor .gh{width:18px;height:18px;fill:currentColor;}
80
92
  .commDoor .btn{text-transform:none;}
81
93
  @media (max-width:480px){
82
- .commRow{grid-template-columns:3ch 32px minmax(0,1fr) auto;gap:0 10px;}
94
+ .commRow,.commRows.canRecruit .commRow{grid-template-columns:3ch 32px minmax(0,1fr) auto;gap:0 10px;}
83
95
  .commRow .connAv{width:32px;height:32px;}
84
96
  .commNum{font-size:19px;}
97
+ /* no room for a fifth column: Recruit drops under the name, as a row's actions do */
98
+ .commRow .commRecruit{grid-column:3/-1;justify-self:start;margin-top:8px;}
99
+ .commGap{display:none;}
85
100
  }
86
101
  </style>
87
102
  </head>
@@ -176,6 +191,7 @@
176
191
  </div>
177
192
  </div>
178
193
  <p class="commNote" id="boardNote">{{Currency}} earned across every project, since each builder started.</p>
194
+ <div class="commFor" id="boardFor" hidden></div>
179
195
  <p class="commMsg" id="boardLoading" role="status">Reading the leaderboard…</p>
180
196
  <p class="projMsg err" id="boardFailed" role="alert" hidden></p>
181
197
  <p class="commMsg" id="boardEmpty" hidden></p>
@@ -201,6 +217,7 @@
201
217
  </select>
202
218
  <button id="findBtn" type="submit" class="btn small">Search</button>
203
219
  </form>
220
+ <div class="commFor" id="findFor" hidden></div>
204
221
  <p class="projMsg" id="findStatus" role="status" aria-live="polite"></p>
205
222
  <ul class="mineRows" id="findList"></ul>
206
223
  </section>
@@ -259,8 +276,9 @@
259
276
  var UNIT = $('boardList').getAttribute('data-unit') || 'credits';
260
277
 
261
278
  /* THREE THINGS THESE PAGES MUST NOT DO (ADR 0217 D1, ADR 0337 D4.1, ADR 0255):
262
- 1. differ for a viewer anywhere but the recruiter list — the leaderboard and the
263
- search are drawn from viewer-independent reads, and neither asks /me;
279
+ 1. differ for a viewer anywhere but the recruiter list and the Recruit door — the
280
+ leaderboard and the search rows come from viewer-independent reads, and a
281
+ project owner only gains a link on each (task 1002292, spec D6);
264
282
  2. draw a recruiter row from anything but a 200 of GET /scouting, or draw more of a
265
283
  name-only row than its name, face and login;
266
284
  3. re-rank, re-filter or re-count what the server sent — rows are drawn in the
@@ -317,6 +335,36 @@
317
335
  return arr(list).map(function (d) { return CRAFT_WORD[d] || String(d); }).join(' · ');
318
336
  }
319
337
 
338
+ // ── the Recruit door: a project owner's link on each row (task 1002292) ─────
339
+ // Who sees it is GET /my-projects' `owned` (owner, 2026-10-27): the hub cannot read a
340
+ // project's project.recruit. Where it goes is invite-notices.js hallInviteUrl's shape,
341
+ // the project's hall invite box, which only fills the name in; the project decides.
342
+ var recruit = { to: [], pick: 0, me: '' };
343
+ function originOf(raw) {
344
+ try {
345
+ var u = new URL(String(raw));
346
+ return (u.protocol === 'https:' || u.protocol === 'http:') ? u.origin : '';
347
+ } catch (e) { return ''; }
348
+ }
349
+ // `keepPlace`: a board row keeps an invisible twin on your own row, so its credits line up
350
+ function recruitCell(r, keepPlace) {
351
+ var p = recruit.to[recruit.pick];
352
+ if (!p) return null;
353
+ var login = String(r.github_login || '');
354
+ if (!login || login.toLowerCase() === recruit.me) {
355
+ if (!keepPlace) return null;
356
+ var gap = el('span', 'btn small ghost commRecruit commGap', 'Recruit');
357
+ gap.setAttribute('aria-hidden', 'true');
358
+ return gap;
359
+ }
360
+ var a = el('a', 'btn small ghost commRecruit', 'Recruit');
361
+ a.setAttribute('href', p.origin + '/builders/watch?invite=' + encodeURIComponent(login));
362
+ a.setAttribute('target', '_blank');
363
+ a.setAttribute('rel', 'noopener');
364
+ a.setAttribute('aria-label', 'Recruit ' + (r.display_name || r.handle || login) + ' to ' + p.name + ' (opens in a new tab)');
365
+ return a;
366
+ }
367
+
320
368
  // ── the leaderboard ─────────────────────────────────────────────────────────
321
369
  var board = { window: 'all', craft: '' };
322
370
  var boardSeq = 0;
@@ -338,6 +386,8 @@
338
386
  credits.appendChild(el('span', 'commNum', num(r.credits).toLocaleString()));
339
387
  credits.appendChild(el('span', 'commUnit', UNIT));
340
388
  li.appendChild(credits);
389
+ var door = recruitCell(r, true);
390
+ if (door) li.appendChild(door);
341
391
  return li;
342
392
  }
343
393
  function emptyText() {
@@ -345,8 +395,11 @@
345
395
  if (board.window === 'all') return 'No ' + who + ' on the leaderboard yet.';
346
396
  return 'No ' + who + ' earned ' + UNIT + ' in this window yet.';
347
397
  }
398
+ var boardShown = null; // the last board drawn, redrawn when the Recruit door changes
348
399
  function paintBoard(results) {
349
400
  var list = $('boardList');
401
+ boardShown = results;
402
+ list.className = 'commRows' + (recruit.to.length ? ' canRecruit' : '');
350
403
  list.textContent = '';
351
404
  results.forEach(function (r) { if (r) list.appendChild(boardRow(r)); });
352
405
  $('boardEmpty').textContent = list.children.length ? '' : emptyText();
@@ -366,6 +419,7 @@
366
419
  if (seq !== boardSeq) return; // a later pick answered first; its rows win
367
420
  $('boardLoading').hidden = true;
368
421
  if (!r.ok) {
422
+ boardShown = null;
369
423
  $('boardList').textContent = '';
370
424
  $('boardEmpty').hidden = true;
371
425
  $('boardFailed').textContent = failText(r.status, 'The leaderboard');
@@ -403,23 +457,35 @@
403
457
  if (c) main.appendChild(el('p', 'mineRow__hint', c));
404
458
  who.appendChild(main);
405
459
  li.appendChild(who);
460
+ var door = recruitCell(r, false);
461
+ if (door) {
462
+ var acts = el('div', 'mineRow__actions');
463
+ acts.appendChild(door);
464
+ li.appendChild(acts);
465
+ }
406
466
  return li;
407
467
  }
468
+ var found = null; // the last results drawn, redrawn when the Recruit door changes
469
+ function paintFound(results) {
470
+ var list = $('findList');
471
+ found = results;
472
+ list.textContent = '';
473
+ if (results) results.forEach(function (p) { if (p) list.appendChild(findRow(p)); });
474
+ }
408
475
  function find() {
409
476
  var q = String($('findQ').value || '').trim();
410
477
  var craft = $('findCraft').value || '';
411
478
  var list = $('findList');
412
- if (q.length < QUERY_MIN) { list.textContent = ''; say('Type at least ' + QUERY_MIN + ' letters of a name.', 'err'); return Promise.resolve(); }
413
- if (q.length > QUERY_MAX) { list.textContent = ''; say('A search is at most ' + QUERY_MAX + ' characters.', 'err'); return Promise.resolve(); }
479
+ if (q.length < QUERY_MIN) { paintFound(null); say('Type at least ' + QUERY_MIN + ' letters of a name.', 'err'); return Promise.resolve(); }
480
+ if (q.length > QUERY_MAX) { paintFound(null); say('A search is at most ' + QUERY_MAX + ' characters.', 'err'); return Promise.resolve(); }
414
481
  var btn = $('findBtn');
415
482
  btn.disabled = true;
416
483
  say('Searching…');
417
484
  var path = '/community/search?q=' + encodeURIComponent(q) + (craft ? '&discipline=' + encodeURIComponent(craft) : '');
418
485
  return read(path).then(function (r) {
419
486
  btn.disabled = false;
420
- list.textContent = '';
421
- if (!r.ok) { say(failText(r.status, 'The search'), 'err'); return; }
422
- arr(r.data.results).forEach(function (p) { if (p) list.appendChild(findRow(p)); });
487
+ if (!r.ok) { paintFound(null); say(failText(r.status, 'The search'), 'err'); return; }
488
+ paintFound(arr(r.data.results));
423
489
  var n = list.children.length;
424
490
  say(n ? plural(n, 'builder') + ' found.' : 'No builder by that name' + (craft ? ' in that role' : '') + '.');
425
491
  });
@@ -449,6 +515,51 @@
449
515
  });
450
516
  }
451
517
 
518
+ // ── the Recruit door's line, and the rows redrawn with it ───────────────────
519
+ function repaintRows() {
520
+ if (boardShown) paintBoard(boardShown);
521
+ if (found) paintFound(found);
522
+ }
523
+ // One project: what Recruit opens. Several: which one it recruits to, drawn once, so a
524
+ // pick redraws only the rows and the choice keeps the focus.
525
+ function paintFor(box) {
526
+ box.textContent = '';
527
+ box.hidden = false;
528
+ if (recruit.to.length === 1) {
529
+ box.textContent = 'Recruit opens ' + recruit.to[0].name + '’s hall with their name filled in, and you send the invite there.';
530
+ return;
531
+ }
532
+ var id = box.id + 'Pick';
533
+ var label = el('label', null, 'Recruit to');
534
+ label.setAttribute('for', id);
535
+ var pick = el('select', 'commForPick');
536
+ pick.id = id;
537
+ recruit.to.forEach(function (p, i) { var o = el('option', null, p.name); o.value = String(i); pick.appendChild(o); });
538
+ pick.value = String(recruit.pick);
539
+ pick.addEventListener('change', function () {
540
+ var i = Number(pick.value);
541
+ if (!recruit.to[i] || i === recruit.pick) return;
542
+ recruit.pick = i;
543
+ repaintRows();
544
+ });
545
+ box.appendChild(label);
546
+ box.appendChild(pick);
547
+ box.appendChild(el('span', null, 'Recruit opens its hall with their name filled in, and you send the invite there.'));
548
+ }
549
+ // A failed or empty read is no door at all: viewing never waits on it.
550
+ function loadOwned(me, box) {
551
+ return read('/my-projects').then(function (r) {
552
+ if (!r.ok) return;
553
+ recruit.me = String(me.github_login || '').toLowerCase();
554
+ recruit.to = arr(r.data.owned).map(function (p) {
555
+ return { name: String((p && (p.name || p.client_id)) || 'your project'), origin: originOf(p && p.origin) };
556
+ }).filter(function (p) { return p.origin; });
557
+ if (!recruit.to.length) return;
558
+ paintFor(box);
559
+ repaintRows();
560
+ });
561
+ }
562
+
452
563
  // ── the sub-page the path names: shown, named, marked current, and read ──────
453
564
  var VIEW = viewOf(window.location.pathname);
454
565
  var view = VIEWS[VIEW];
@@ -461,6 +572,7 @@
461
572
  $('signinTop').setAttribute('href', '/api/bongos/auth/web/start?return=' + view.path + '&repos=1');
462
573
  if (VIEW === 'leaderboard') loadBoard();
463
574
  if (VIEW === 'recruiters') bar.ready.then(function (me) { if (me) loadRecruit(); else showRecruit('recruitDoor'); });
575
+ else bar.ready.then(function (me) { if (me) loadOwned(me, $(VIEW === 'find' ? 'findFor' : 'boardFor')); });
464
576
  })();
465
577
  </script>
466
578
  <script src="/assets/invite-badge.js"></script>
@@ -0,0 +1 @@
1
+ {"_":"GET /my-projects for a builder who owns TWO projects (task 1002292): the community pages' Recruit door asks which project it recruits to.","owned":[{"client_id":"mercury","name":"Mercury","origin":"https://mercury.example.test","hall_origin":null,"membership_kind":"owner","joined_at":"2026-08-01T10:00:00.000Z","last_active":"2026-10-20T18:00:00.000Z"},{"client_id":"tidepool","name":"Tidepool","origin":"https://tidepool.example.test","hall_origin":null,"membership_kind":"owner","joined_at":"2026-09-02T10:00:00.000Z","last_active":"2026-10-24T18:00:00.000Z"}],"building":[],"applied":[]}
@@ -0,0 +1 @@
1
+ {"_":"GET /my-projects for a builder who owns ONE project (task 1002292): the community pages' Recruit door shows on every row and opens Mercury's hall invite box.","owned":[{"client_id":"mercury","name":"Mercury","origin":"https://mercury.example.test","hall_origin":null,"membership_kind":"owner","joined_at":"2026-08-01T10:00:00.000Z","last_active":"2026-10-20T18:00:00.000Z"}],"building":[{"client_id":"otb","name":"Example World","origin":"https://example.com","hall_origin":"https://builders.example.com","membership_kind":"member","joined_at":"2026-07-20T10:00:00.000Z","last_active":"2026-08-27T18:00:00.000Z"}],"applied":[]}
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.11",
3
+ "version": "1.23.13",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.23.11",
9
+ "version": "1.23.13",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.11",
3
+ "version": "1.23.13",
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",
@@ -10453,5 +10453,17 @@
10453
10453
  "id": "1004256",
10454
10454
  "text": "Signed-in builders now find 'Builders you may know' in the Community menu; it opens their suggestions on the Connections page, which says so plainly when there's no one to suggest yet."
10455
10455
  }
10456
+ ],
10457
+ "1.23.12": [
10458
+ {
10459
+ "id": "1002292",
10460
+ "text": "Project owners can now press Recruit next to any builder on the community leaderboard or search, which opens their project's invite page with that builder's name already filled in."
10461
+ }
10462
+ ],
10463
+ "1.23.13": [
10464
+ {
10465
+ "id": "1004644",
10466
+ "text": "The tweak editor's split view now shows the landing and status pages under the words, instead of opening a second window."
10467
+ }
10456
10468
  ]
10457
10469
  }
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.23.11'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.13'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -675,11 +675,13 @@ try {
675
675
  await test('split view can frame only its own host; the live site refuses to be framed from another', () => {
676
676
  const here = { origin: 'https://builders.cloudbongos.com', href: 'https://builders.cloudbongos.com/builders/tweak-editor' };
677
677
  assert.equal(lib.canFrame('https://builders.cloudbongos.com/studio', here), true);
678
- assert.equal(lib.canFrame('https://cloudbongos.com/', here), false, 'the apex is another origin');
678
+ assert.equal(lib.canFrame('https://cloudbongos.com/', here), true, 'the apex is a sibling the proxy lets the builders host frame');
679
+ assert.equal(lib.canFrame('https://cloudbongos.com/', here, false), false, 'the apex is another origin when siblings are off');
679
680
  assert.equal(lib.canFrame('not a url', { origin: '', href: '' }), false);
680
681
  // task 1004393: the apex and status pages split in place only once the proxy allows it.
681
- assert.equal(lib.FRAME_SIBLINGS, false, 'off until the proxy sends frame-ancestors for the builders host');
682
- assert.equal(lib.canFrame('https://status.cloudbongos.com/', here), false, 'the default follows the switch');
682
+ assert.equal(lib.FRAME_SIBLINGS, true, 'on: the proxy sends frame-ancestors for the builders host (task 1004644)');
683
+ assert.equal(lib.canFrame('https://status.cloudbongos.com/', here), true, 'the default follows the switch');
684
+ assert.equal(lib.canFrame('https://status.cloudbongos.com/', here, false), false, 'switched off, only the editor host frames');
683
685
  assert.equal(lib.canFrame('https://cloudbongos.com/pricing', here, true), true, 'the apex, switched on');
684
686
  assert.equal(lib.canFrame('https://status.cloudbongos.com/', here, true), true, 'the status host, switched on');
685
687
  assert.equal(lib.canFrame('https://box.cloudbongos.com/', here, true), false, 'never another subdomain');
@@ -791,19 +793,14 @@ try {
791
793
  } finally { PAGE.url = saved; }
792
794
  });
793
795
 
794
- await test('a page on the apex splits into its own window docked to the bottom half, and never on load', async () => {
796
+ await test('a page on the apex splits in place under the words, with no second window (task 1004644)', async () => {
795
797
  const b = bootEditor({
796
798
  answers: { 'GET /copy-desk/pages/landing:index/draft': ok(editorRead()) },
797
799
  stored: { 'hall-tweak-open-as': 'split', 'hall-tweak-split': 'on' },
798
800
  });
799
801
  await b.settle();
800
- assert.equal(b.opened.length, 0, 'no popup on load: a browser would block it');
801
- assert.equal(b.el('tw-frame').hidden, true, 'nothing framed: the site would refuse');
802
- await b.click('tw-open');
803
- assert.equal(b.opened.length, 1);
804
- assert.deepEqual(b.opened[0].slice(0, 2), ['https://cloudbongos.com/', 'tw-live-page']);
805
- assert.equal(b.opened[0][2], 'popup=yes,width=1600,height=500,left=0,top=500');
806
- assert.equal(b.el('tw-frame').hidden, true);
802
+ assert.equal(b.opened.length, 0, 'no popup: the page is framed in place');
803
+ assert.equal(b.el('tw-frame').hidden, false, 'the apex page shows under the words');
807
804
  });
808
805
 
809
806
  // task 1004776: a guarded line shows what it must still say under it, and the
@@ -78,6 +78,7 @@ test('no top block: one visually hidden h1, then one section per sub-page, only
78
78
  assert.match(MAIN, /<ol class="commRows" id="boardList" data-unit="\{\{currency\}\}" aria-labelledby="boardH"><\/ol>/, 'the unit is the instance’s own word, stamped by the server');
79
79
  assert.match(MAIN, /<p class="projMsg err" id="boardFailed" role="alert" hidden><\/p>/);
80
80
  assert.match(MAIN, /<form class="mineJoinRow commFind" id="findForm" role="search"/);
81
+ for (const id of ['boardFor', 'findFor']) assert.match(MAIN, new RegExp(`<div class="commFor" id="${id}" hidden></div>`), `${id}: the Recruit door’s line waits for GET /my-projects (task 1002292)`);
81
82
  assert.match(MAIN, /<h2 id="recruitH">Open to recruiters<\/h2>/, 'the hide flag is "hidden", never "private" (ADR 0337)');
82
83
  assert.match(MAIN, /<a class="btn" id="recruitSignin" href="\/api\/bongos\/auth\/web\/start\?return=\/community\/recruiters&amp;repos=1">/);
83
84
  for (const id of ['recruitDoor', 'recruitClosed', 'recruitFailed', 'recruitEmpty']) assert.match(MAIN, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
@@ -100,11 +101,12 @@ test('the window dropdown and the query limits are the routes’ own (boundary-f
100
101
  assert.match(MAIN, new RegExp(`<input id="findQ" type="search"[^>]* maxlength="${max}"`));
101
102
  });
102
103
 
103
- test('the script builds no markup from strings, reads only the three routes, and never re-ranks or reads the ranked tier', () => {
104
+ test('the script builds no markup from strings, reads only the four routes, and never re-ranks or reads the ranked tier', () => {
104
105
  assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
105
- // every path literal in the code: the API base, the three reads, the three sub-pages and the sign-in door
106
+ // every path literal in the code: the API base, the four reads, the three sub-pages, the
107
+ // sign-in door and the hall's invite box the Recruit door opens (task 1002292)
106
108
  const paths = [...new Set([...CODE.matchAll(/'(\/[^'?]*)/g)].map((m) => m[1]))];
107
- assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/community', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/scouting']);
109
+ assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/builders/watch', '/community', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/my-projects', '/scouting']);
108
110
  assert.doesNotMatch(CODE, /method:/, 'it only reads');
109
111
  assert.doesNotMatch(CODE, /'\/me\b/, 'who is signed in is the bar’s question');
110
112
  assert.doesNotMatch(CODE, /\.sort\(|\.count\b|_count\b/, 'the server ranks; every count is a list’s length');
@@ -173,7 +175,9 @@ async function boot({ pathname = '/community', me = null, reads = READS() } = {}
173
175
  requests.push({ url, credentials: init.credentials });
174
176
  const a = reads[url.replace(/^\/api\/bongos/, '')];
175
177
  if (!a || a.reject) return Promise.reject(new Error('offline'));
176
- return Promise.resolve({ ok: a.status >= 200 && a.status < 300, status: a.status, json: () => Promise.resolve(a.body) });
178
+ const res = { ok: a.status >= 200 && a.status < 300, status: a.status, json: () => Promise.resolve(a.body) };
179
+ // `slow`: answers on a timer, after every microtask-settled read (the order a slow network gives)
180
+ return a.slow ? new Promise((resolve) => setTimeout(() => resolve(res), 5)) : Promise.resolve(res);
177
181
  };
178
182
  const document = {
179
183
  title: 'Cloud Bongos — Community',
@@ -225,7 +229,8 @@ test('the leaderboard, signed out: the all-time board in the server’s order wi
225
229
  assert.equal(find(bo, 'mineRow__hint').textContent, '1 work shipped · 0 karma');
226
230
  assert.equal(ui.$('boardNote').textContent, 'Credits earned across every project, since each builder started.');
227
231
  const signedIn = await boot({ me: { github_login: 'someone' } });
228
- assert.deepEqual(signedIn.gets(), ['/api/bongos/community/leaderboard?window=all'], 'signed in, the same one read: viewing is ungated');
232
+ assert.deepEqual(signedIn.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/my-projects'], 'signed in, the same board read: viewing is ungated, and the one more read is the Recruit door’s');
233
+ assert.equal(signedIn.$('boardList').textContent, ui.$('boardList').textContent, 'the same rows for every viewer');
229
234
  });
230
235
 
231
236
  test('the window dropdown and a craft are each one read; the pressed craft follows the pick', async () => {
@@ -320,6 +325,114 @@ test('recruiters: a door signed out, a refusal on a 403, rows only from a 200 as
320
325
  assert.equal(failed.$('recruitFailed').textContent, 'This list could not be read. Reload the page to try again.');
321
326
  });
322
327
 
328
+ // ── 2b. the Recruit door (task 1002292; spec D6, owner 2026-10-27) ───────────
329
+ // A project OWNER gets a Recruit link on each row: the hub cannot read a project's
330
+ // `project.recruit` (ADR 0335 D1), so GET /my-projects' `owned` stands in. The link is the
331
+ // project's own hall invite box with the GitHub login filled in (ADR 0353), and the
332
+ // project decides.
333
+
334
+ const OWNED = (...ps) => ({ status: 200, body: {
335
+ owned: ps.map(([client_id, origin, name]) => ({ client_id, name, origin, hall_origin: null, membership_kind: 'owner' })),
336
+ building: [{ client_id: 'zed', name: 'Zed', origin: 'https://zed.example', hall_origin: null, membership_kind: 'member' }],
337
+ applied: [],
338
+ } });
339
+ const recruitOf = (row) => find(row, 'commRecruit');
340
+
341
+ test('recruit: an owner gets a Recruit link on every row but their own, opening their project’s hall invite box with the GitHub login filled in', async () => {
342
+ const reads = READS();
343
+ reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example/x?y=1', 'Mercury']);
344
+ const ui = await boot({ me: { github_login: 'BO' }, reads });
345
+ assert.deepEqual(ui.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/my-projects']);
346
+ const [ana, bo] = kids(ui.$('boardList'));
347
+ const a = recruitOf(ana);
348
+ assert.equal(a.tag, 'a');
349
+ assert.equal(a.textContent, 'Recruit');
350
+ assert.equal(a.attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'the project’s origin and nothing else of the address');
351
+ assert.equal(a.attrs.target, '_blank', 'the board stays open behind it');
352
+ assert.equal(a.attrs.rel, 'noopener');
353
+ assert.equal(a.attrs['aria-label'], 'Recruit ANA to Mercury (opens in a new tab)', 'the new tab is said, not sprung');
354
+ assert.equal(recruitOf(bo).tag, 'span', 'your own row: an invisible twin keeps the credits in line');
355
+ assert.equal(recruitOf(bo).attrs['aria-hidden'], 'true');
356
+ assert.match(ui.$('boardList').className, /\bcanRecruit\b/);
357
+ assert.equal(ui.$('boardFor').hidden, false);
358
+ assert.equal(ui.$('boardFor').textContent, 'Recruit opens Mercury’s hall with their name filled in, and you send the invite there.');
359
+ await ui.pick('30d');
360
+ assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=cy', 'a new window’s rows carry it too');
361
+ reads['/community/leaderboard?window=all'].slow = true;
362
+ const late = await boot({ me: { github_login: 'BO' }, reads });
363
+ assert.equal(kids(late.$('boardList')).length, 0, 'the board has not answered yet');
364
+ assert.equal(late.$('boardFor').hidden, false, 'the owner’s line is up before the board');
365
+ await new Promise((resolve) => setTimeout(resolve, 20));
366
+ await settle();
367
+ assert.equal(recruitOf(kids(late.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'a board that answers after GET /my-projects draws its doors as it paints');
368
+ delete reads['/community/leaderboard?window=all'].slow;
369
+ reads['/community/leaderboard?window=all'].body.results[0].github_login = 'a b&c';
370
+ const odd = await boot({ me: { github_login: 'x' }, reads });
371
+ assert.equal(recruitOf(kids(odd.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=a%20b%26c', 'the login is encoded into the query');
372
+ });
373
+
374
+ test('recruit: none for a builder who owns no project, signed out, on a failed read, or for a project with no web address; the rows stay the same', async () => {
375
+ const reads = READS();
376
+ reads['/my-projects'] = OWNED();
377
+ const member = await boot({ me: { github_login: 'someone' }, reads });
378
+ assert.equal(member.gets().pop(), '/api/bongos/my-projects');
379
+ assert.ok(kids(member.$('boardList')).every((r) => !recruitOf(r)), 'building on a project is not owning one');
380
+ assert.equal(member.$('boardFor').hidden, true);
381
+ assert.doesNotMatch(member.$('boardList').className, /canRecruit/);
382
+ const out = await boot({ reads });
383
+ assert.deepEqual(out.gets(), ['/api/bongos/community/leaderboard?window=all'], 'signed out, nobody is asked about projects');
384
+ for (const answer of [{ status: 500, body: {} }, { status: 401, body: {} }, { reject: true }, OWNED(['evil', 'javascript:alert(1)', 'Evil'], ['none', null, 'None'])]) {
385
+ reads['/my-projects'] = answer;
386
+ const ui = await boot({ me: { github_login: 'someone' }, reads });
387
+ assert.ok(kids(ui.$('boardList')).every((r) => !recruitOf(r)), 'no door, and the board is drawn all the same');
388
+ assert.equal(kids(ui.$('boardList')).length, 2);
389
+ assert.equal(ui.$('boardFailed').hidden, true, 'a failed projects read is never the board’s failure');
390
+ assert.equal(ui.$('boardFor').hidden, true);
391
+ }
392
+ });
393
+
394
+ test('recruit: several projects are one “Recruit to” choice, and picking one re-points every row', async () => {
395
+ const reads = READS();
396
+ reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example', 'Mercury'], ['venus', 'http://venus.example:8080', 'Venus']);
397
+ const ui = await boot({ me: { github_login: 'nobody' }, reads });
398
+ const pick = find(ui.$('boardFor'), 'commForPick');
399
+ assert.equal(pick.tag, 'select');
400
+ assert.equal(pick.id, 'boardForPick');
401
+ assert.deepEqual(kids(pick).map((o) => [o.value, o.textContent]), [['0', 'Mercury'], ['1', 'Venus']]);
402
+ assert.equal(kids(ui.$('boardFor'))[0].attrs.for, 'boardForPick', 'the choice is labelled');
403
+ assert.match(ui.$('boardFor').textContent, /^Recruit to/);
404
+ assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'the first project, as the server listed them');
405
+ pick.value = '1';
406
+ for (const f of pick._on.change || []) f({});
407
+ await settle();
408
+ const [ana, bo] = kids(ui.$('boardList'));
409
+ assert.equal(recruitOf(ana).attrs.href, 'http://venus.example:8080/builders/watch?invite=ana');
410
+ assert.equal(recruitOf(bo).attrs['aria-label'], 'Recruit bo to Venus (opens in a new tab)');
411
+ assert.equal(find(ui.$('boardFor'), 'commForPick'), pick, 'the choice is not redrawn, so it keeps the focus');
412
+ pick.value = '7';
413
+ for (const f of pick._on.change || []) f({});
414
+ assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'http://venus.example:8080/builders/watch?invite=ana', 'a value it never offered changes nothing');
415
+ });
416
+
417
+ test('recruit: found builders carry it too; the recruiter list never asks', async () => {
418
+ const reads = READS();
419
+ reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example', 'Mercury']);
420
+ const ui = await boot({ pathname: '/community/find', me: { github_login: 'ph' }, reads });
421
+ assert.deepEqual(ui.gets(), ['/api/bongos/my-projects'], 'nothing searched until asked');
422
+ assert.equal(ui.$('findFor').hidden, false);
423
+ assert.equal(ui.$('findFor').textContent, 'Recruit opens Mercury’s hall with their name filled in, and you send the invite there.');
424
+ assert.equal(ui.$('boardFor').hidden, true, 'only the shown sub-page’s line');
425
+ ui.$('findQ').value = 'pri';
426
+ await ui.submit();
427
+ const [priya, hidden] = kids(ui.$('findList'));
428
+ const acts = find(priya, 'mineRow__actions');
429
+ assert.equal(recruitOf(acts).attrs.href, 'https://mercury.example/builders/watch?invite=priyan', 'by GitHub login, never the handle');
430
+ assert.equal(recruitOf(hidden), null, 'your own row: no door, and no twin in a row that keeps no columns');
431
+ assert.equal(ui.$('findStatus').textContent, '2 builders found.');
432
+ const recruiters = await boot({ pathname: '/community/recruiters', me: { github_login: 'owner' }, reads });
433
+ assert.deepEqual(recruiters.gets(), ['/api/bongos/scouting'], 'the recruiter list draws no Recruit door');
434
+ });
435
+
323
436
  // ── 3. every bar carries the Community menu ──────────────────────────────────
324
437
 
325
438
  const SERVES_A_CHILD = ['privacy.html', 'terms.html', 'contact.html', 'index.html', 'projects.html'];
@@ -437,11 +550,20 @@ test('the kit renders each sub-page for everyone, the empty board, and a project
437
550
  assert.equal(states.states['out-find'].url, '/community/find');
438
551
  assert.equal(states.states['in-recruiters'].url, '/community/recruiters');
439
552
  assert.ok(states.states['in-recruiters'].expect.visible.includes('#recruitClosed'), 'signed in but not admitted: told, shown no one');
440
- for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter']]) {
553
+ for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter'], ['community-owner.states.json', 'my-projects', 'my-projects-owner'], ['community-owner-two.states.json', 'my-projects', 'my-projects-owner-two']]) {
441
554
  assert.equal(json(file).stub.fixtures[name], fixture);
442
555
  assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', fixture + '.json')), `fixture ${fixture}`);
443
556
  }
444
557
  assert.equal(json('community-recruiter.states.json').states['in-recruiter'].url, '/community/recruiters');
558
+ // the Recruit door (task 1002292): an owner of one project on the board and the search, and of two with the choice
559
+ assert.ok(json('community-owner.states.json').states['in-owner'].expect.count.some(([sel, n]) => sel === '#boardList a.commRecruit' && n === 6));
560
+ assert.equal(json('community-owner.states.json').states['in-owner-find'].url, '/community/find');
561
+ assert.ok(json('community-owner-two.states.json').states['in-owner-two-picked'].actions.some((a) => a[0] === 'select' && a[1] === '#boardForPick'));
562
+ for (const f of ['my-projects-owner', 'my-projects-owner-two']) {
563
+ const owned = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', f + '.json'), 'utf8')).owned;
564
+ assert.ok(owned.length && owned.every((p) => p.membership_kind === 'owner' && /^https:\/\//.test(p.origin)), `${f}: owned projects with web addresses`);
565
+ }
566
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'my-projects.json'), 'utf8')).owned, [], 'the default builder owns nothing, so every other state draws no door');
445
567
  });
446
568
 
447
569
  function stub(env = {}) {