@bongos/core 1.19.578 → 1.19.580

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.bongos-core.json +68 -38
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md +4 -1
  8. package/docs/adr/0259-a-projects-departure-from-the-public-list-is-public.md +128 -0
  9. package/docs/adr/0260-the-application-is-the-consent-and-the-echo-is-the-gate.md +146 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/api/openapi.json +73 -3
  12. package/docs/api-reference.md +3 -2
  13. package/docs/copy-inventory.md +23 -21
  14. package/docs/copy-registry.json +51 -33
  15. package/docs/file-map.md +1 -0
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  18. package/modules/hall-ui/public/oversight.css +7 -0
  19. package/modules/hall-ui/public/watch.js +75 -0
  20. package/modules/onboarding/routes/access-requests.js +56 -1
  21. package/modules/platform-identity/applicant-profile.js +158 -0
  22. package/modules/platform-identity/application-echo.js +50 -0
  23. package/modules/platform-identity/recruiter-sliver.js +19 -0
  24. package/modules/platform-identity/routes/sso.js +126 -0
  25. package/modules/public-landing/public/projects.html +2 -2
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/src/bongos/auth-admission.js +61 -0
  29. package/src/bongos/auth.js +2 -1
  30. package/src/module-api.js +11 -1
  31. package/tests/adr_renumber_integrity.mjs +84 -0
  32. package/tests/applicant_profile_boundary.mjs +641 -0
  33. package/tests/applicant_profile_read_bounds.mjs +95 -0
  34. package/tests/module_api.mjs +1 -0
  35. package/tests/projects_hub.mjs +24 -0
  36. package/tests/recruiter_sliver_boundary.mjs +35 -2
  37. package/tests/watch_applications_queue.mjs +94 -0
@@ -0,0 +1,95 @@
1
+ // tests/applicant_profile_read_bounds.mjs — the one hub call that FANS OUT is
2
+ // bounded in time (task 1003681, from a grader finding).
3
+ //
4
+ // THE DEFECT. `readApplicantProfileFromHub()` had no timeout on its fetch to the
5
+ // hub. Its own doc-comment already promised the outcome — "a queue that fails to
6
+ // load because the hub is slow is a broken one" — but nothing enforced it.
7
+ //
8
+ // WHY IT MATTERS MORE HERE THAN FOR ITS TWO SIBLINGS. reportMembershipCheckIn
9
+ // and reportActivityRollup fire at most once per sign-in or per ship. This one
10
+ // runs up to MAX_LIVE_PROFILE_READS (25) times CONCURRENTLY inside the
11
+ // Promise.all that `GET /access-requests` awaits before responding (D7 resolves
12
+ // the profile live on every render, deliberately — a snapshot would keep
13
+ // publishing a consent since withdrawn). So one unresponsive hub held the whole
14
+ // reviewer queue open for as long as the sockets lived, 25 at a time.
15
+ //
16
+ // The fix is a bound, not a behaviour change: every other failure mode here
17
+ // already answers `null`, so a timeout simply joins them.
18
+ //
19
+ // DB-free and network-free: auth-config is pinned in require.cache so the
20
+ // federation config resolves without env, and global fetch is a spy.
21
+ //
22
+ // Run: node --test --test-reporter=tap tests/applicant_profile_read_bounds.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { createRequire } from 'node:module';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+
34
+ // Pin the federation config BEFORE auth-admission loads, so loadIdpConfig()
35
+ // answers without HUB_* env (the tests/profile_route.mjs require.cache idiom).
36
+ const cfgPath = require.resolve('../src/bongos/auth-config.js');
37
+ const realCfg = require('../src/bongos/auth-config.js');
38
+ require.cache[cfgPath] = {
39
+ id: cfgPath,
40
+ filename: cfgPath,
41
+ loaded: true,
42
+ exports: {
43
+ ...realCfg,
44
+ loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'cid', clientSecret: 'sec' }),
45
+ },
46
+ };
47
+
48
+ const admission = require('../src/bongos/auth-admission.js');
49
+
50
+ function withFetch(impl, run) {
51
+ const real = globalThis.fetch;
52
+ globalThis.fetch = impl;
53
+ return Promise.resolve(run()).finally(() => { globalThis.fetch = real; });
54
+ }
55
+
56
+ test('the hub read carries an abort signal — the queue can never wait on it forever', async () => {
57
+ // Arrange
58
+ let seen = null;
59
+ const ok = { ok: true, json: async () => ({ profile: { bio: 'hi' } }) };
60
+
61
+ // Act
62
+ const profile = await withFetch(async (_url, init) => { seen = init; return ok; },
63
+ () => admission.readApplicantProfileFromHub(42));
64
+
65
+ // Assert
66
+ assert.deepEqual(profile, { bio: 'hi' }, 'the happy path still returns the hub’s view');
67
+ assert.ok(seen, 'fetch was called');
68
+ assert.ok(seen.signal, 'an AbortSignal must be passed — without one the call is unbounded');
69
+ assert.equal(typeof seen.signal.aborted, 'boolean', 'and it must be a real AbortSignal');
70
+ assert.equal(seen.signal.aborted, false, 'not already aborted when the request goes out');
71
+ });
72
+
73
+ test('a timed-out hub answers null, exactly like every other failure here', async () => {
74
+ // Arrange — what AbortSignal.timeout produces when the budget expires
75
+ const abortErr = Object.assign(new Error('This operation was aborted'), { name: 'TimeoutError' });
76
+
77
+ // Act
78
+ const profile = await withFetch(async () => { throw abortErr; },
79
+ () => admission.readApplicantProfileFromHub(42));
80
+
81
+ // Assert — a degraded queue, never a broken one
82
+ assert.equal(profile, null, 'an abort must fail soft to null, not reject into the route');
83
+ });
84
+
85
+ test('the timeout is a real, small budget — a 25-wide fan-out cannot outlive it', () => {
86
+ // Structural: the constant is the bound the route inherits, so it is pinned
87
+ // rather than measured (a real 5s wait would make this suite sleep).
88
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth-admission.js'), 'utf8');
89
+ const m = /const APPLICANT_PROFILE_TIMEOUT_MS = (\d+);/.exec(src);
90
+ assert.ok(m, 'the budget must be a named constant, not an inline number');
91
+ const ms = Number(m[1]);
92
+ assert.ok(ms > 0 && ms <= 10000, `the budget must be small and real (got ${ms}ms)`);
93
+ assert.match(src, /signal: AbortSignal\.timeout\(APPLICANT_PROFILE_TIMEOUT_MS\)/,
94
+ 'and the fetch must actually use it — the constant alone bounds nothing');
95
+ });
@@ -38,6 +38,7 @@ const PUBLISHED_SURFACE = [
38
38
  'resolveRotDays', 'projectSettings', // added by task 1003279 (ADR 0232; goal 1000072): the ROT timer + the live knob store behind it. A DIFFERENT question from the stale-claim timer above — days not hours, DB not env, work nobody picked up rather than a builder who went quiet, and it only ever ASKS a human where that one ACTS. Both ride the doorway so no module hardcodes either.
39
39
  'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
40
40
  '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
41
+ '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
41
42
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
42
43
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
43
44
  'on', 'emit', 'emitAsync', 'listEvents', 'seamSnapshot',
@@ -1543,6 +1543,30 @@ test('the join-door labels are PLAIN — the space metaphor is the orb’s, neve
1543
1543
  assert.doesNotMatch(options, /unlisted/i, 'dark matter is DERIVED — there is deliberately no way to choose it');
1544
1544
  });
1545
1545
 
1546
+ /* ADR 0259: the one thing Stealth does NOT hide is the switch itself. A project
1547
+ leaving the named list while the anonymous count rises names it to anyone
1548
+ holding yesterday's feed — inherent in publishing a named list, and the owner
1549
+ chose to accept it and say so rather than pay for a delay or decoys. The
1550
+ copy is the whole fix, so the copy is what gets pinned: without this, a
1551
+ future edit tightening the blurb drops the caveat and the page goes back to
1552
+ implying a guarantee the feed does not make. */
1553
+ test('the Stealth copy names its own limit — switching is visible, even though the project is not (ADR 0259)', () => {
1554
+ const options = (SCRIPT.match(/var DOOR_OPTIONS = \{[\s\S]*?\n \};/) || [''])[0];
1555
+ const fold = (HUB.match(/<details class="finePrint" id="mDoorWhat">[\s\S]*?<\/details>/) || [''])[0];
1556
+ assert.ok(options && fold, 'both the option table and the fold must be extractable');
1557
+ for (const [where, text] of [['the choice’s blurb', options], ['the what-changes fold', fold]]) {
1558
+ assert.match(text, /Switching to Stealth is itself visible/,
1559
+ `${where} states the boundary — a privacy control that oversells is worse than one that admits a limit`);
1560
+ assert.match(text, /comparing the map before and after can tell which project went quiet/,
1561
+ `${where} says HOW it is visible, so the owner can judge it rather than take our word`);
1562
+ }
1563
+ /* the plain-register rule this card is held to still binds the new sentence
1564
+ (the space metaphor is the drawn orb's, never the copy's) */
1565
+ for (const word of ['planet', 'star', 'black hole', 'dark matter', 'feed', 'snapshot']) {
1566
+ assert.doesNotMatch(options.toLowerCase(), new RegExp(`\\b${word}\\b`), `"${word}" must not reach a choice’s blurb`);
1567
+ }
1568
+ });
1569
+
1546
1570
  test('the join-door pick is a real radio group built from the server’s own options list', () => {
1547
1571
  const render = fnHub('renderDoor');
1548
1572
  assert.match(render, /\(s\.options \|\| \[\]\)\.filter\(function \(v\) \{ return !!DOOR_OPTIONS\[v\]; \}\)/,
@@ -803,6 +803,25 @@ test('each route spells its OWN gate in its OWN middleware list', () => {
803
803
  // 7. IT GRANTS NOTHING (ADR 0016)
804
804
  // ===========================================================================
805
805
 
806
+ // The first-party files allowed to read the sliver module, each with the reason
807
+ // it is not a DECIDER. The list is the assertion: a new name here is a claim
808
+ // that must be argued, not a line added to make a red test green.
809
+ //
810
+ // routes/scouting.js serializes the sliver onto its one archon-gated
811
+ // route. The original sole reader.
812
+ // applicant-profile.js reuses the PROJECTION for the D7 applicant view
813
+ // (task 1002972) and supplies its own consent floor —
814
+ // see sliverShapeFor's contract. Sharing the
815
+ // projection is what keeps D7's "no new disclosure
816
+ // class" literally true: the alternative was a second
817
+ // five-key object free to drift from this one field
818
+ // by field, which this whitelist proof would not
819
+ // cover. It reads the shape; it decides nothing.
820
+ const SLIVER_READERS = [
821
+ 'modules/platform-identity/applicant-profile.js',
822
+ 'modules/platform-identity/routes/scouting.js',
823
+ ];
824
+
806
825
  test('the sliver module is read by exactly one first-party server file — the one that serializes it', () => {
807
826
  const files = [];
808
827
  const walk = (dir) => {
@@ -817,8 +836,22 @@ test('the sliver module is read by exactly one first-party server file — the o
817
836
  const readers = files
818
837
  .filter((f) => /require\(\s*['"][^'"]*recruiter-sliver['"]\s*\)/.test(fs.readFileSync(f, 'utf8')))
819
838
  .map((f) => path.relative(ROOT, f).split(path.sep).join('/'));
820
- assert.deepEqual(readers, ['modules/platform-identity/routes/scouting.js'],
821
- 'nothing may consult the sliver to DECIDE anything: cross-project numbers are descriptive analytics, never authority (ADR 0016)');
839
+ assert.deepEqual(readers, SLIVER_READERS,
840
+ 'nothing may consult the sliver to DECIDE anything: cross-project numbers are descriptive analytics, never authority (ADR 0016). '
841
+ + 'A new reader needs a stated reason in SLIVER_READERS — and it must serialize the shape, never branch on it.');
842
+
843
+ // The reason each allowance rests on, enforced rather than trusted: a reader
844
+ // may PROJECT the sliver, never READ A VALUE OUT OF IT to decide something. A
845
+ // comparison against a sliver field is what "authority" would look like here,
846
+ // so the shape of that mistake is what this refuses.
847
+ for (const rel of SLIVER_READERS) {
848
+ const code = fs.readFileSync(path.join(ROOT, rel), 'utf8')
849
+ .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
850
+ for (const key of SLIVER_KEYS) {
851
+ assert.doesNotMatch(code, new RegExp(`\\.${key}\\s*(?:===|!==|==|!=|>|<)`),
852
+ `${rel} compares a sliver's \`${key}\` — a reader that BRANCHES on this data is consulting descriptive analytics as authority (ADR 0016)`);
853
+ }
854
+ }
822
855
  });
823
856
 
824
857
  test('no rank, role, permission or membership travels with the sliver, under any name', async () => {
@@ -378,4 +378,98 @@ await test('the section lede tells the owner the queue is more than one decision
378
378
  assert.match(h.lede(), /decided/i);
379
379
  });
380
380
 
381
+ // ===========================================================================
382
+ // The applicant's own profile beside their ask (task 1002972, privacy spec D7)
383
+ // ===========================================================================
384
+ //
385
+ // The SERVER decides what may be disclosed — tests/applicant_profile_boundary.mjs
386
+ // is that proof. What is asserted HERE is the half a reviewer actually sees: that
387
+ // the row draws whichever shape it was handed, draws NOTHING when handed null,
388
+ // and escapes the applicant's own words. A source-text assertion cannot see any
389
+ // of that; this runs the real watch.js over the real markup.
390
+
391
+ const PUBLIC_PROFILE = {
392
+ view: 'public',
393
+ account: {
394
+ handle: 'mona', github_login: 'octocat', display_name: 'Mona Lisa',
395
+ avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
396
+ member_since: '2025-01-01T00:00:00Z', bio: 'I draw maps.', links: null,
397
+ },
398
+ cross_project: {
399
+ totals: { projects: 3, credits: 4200, works_shipped: 41, karma: 7 },
400
+ disciplines: ['engineer', 'artist'], last_active: '2026-08-30T00:00:00Z',
401
+ projects: [], more_projects: 0,
402
+ },
403
+ };
404
+ const SLIVER_PROFILE = {
405
+ view: 'sliver',
406
+ sliver: { handle: 'quiet', display_name: 'Quiet Person', avatar_url: null, disciplines: ['engineer'], active_recently: true },
407
+ };
408
+ const withProfile = (profile, over = {}) => [{ ...WAITING[0], profile, ...over }];
409
+
410
+ await test('a PUBLIC applicant renders their record beside the ask', async () => {
411
+ const h = await boot({ rows: { pending: withProfile(PUBLIC_PROFILE), invited: [], dismissed: [] } });
412
+ const block_ = h.rows()[0].querySelector('.ov-row__profile');
413
+ assert.ok(block_, 'the public applicant view rendered no profile block at all');
414
+ const text = block_.textContent;
415
+ assert.match(text, /I draw maps\./, 'the bio is part of what the public page already shows');
416
+ assert.match(text, /3 projects/);
417
+ assert.match(text, /41 shipped/);
418
+ assert.match(text, /engineer/);
419
+ });
420
+
421
+ await test('a PRIVATE applicant renders the sliver AND says why it is thin', async () => {
422
+ const h = await boot({ rows: { pending: withProfile(SLIVER_PROFILE), invited: [], dismissed: [] } });
423
+ const block_ = h.rows()[0].querySelector('.ov-row__profile');
424
+ assert.ok(block_, 'the sliver rendered no profile block');
425
+ const text = block_.textContent;
426
+ assert.match(text, /Private account/,
427
+ 'a reviewer must not read the thinness as a poor record — it is a boundary, and applying is what opened even this much');
428
+ assert.match(text, /engineer/);
429
+ assert.match(text, /Active recently/);
430
+ });
431
+
432
+ await test('hidden stats: a public applicant keeps their name and says the record is hidden', async () => {
433
+ const hidden = { ...PUBLIC_PROFILE, cross_project: null };
434
+ const h = await boot({ rows: { pending: withProfile(hidden), invited: [], dismissed: [] } });
435
+ const text = h.rows()[0].querySelector('.ov-row__profile').textContent;
436
+ assert.match(text, /hides their cross-project record/,
437
+ 'a blank would read as "this applicant has done nothing", which is a different claim entirely');
438
+ });
439
+
440
+ await test('NO profile is not an error — the row renders exactly as it always did', async () => {
441
+ const h = await boot({ rows: { pending: withProfile(null), invited: [], dismissed: [] } });
442
+ const row = h.rows()[0];
443
+ assert.equal(row.querySelector('.ov-row__profile'), null,
444
+ 'null covers unvouched, no hub, hub down and hidden alike — none of them may draw a box');
445
+ assert.ok(row.querySelector('.fact-pill'), 'and the vouch pill still says whether anyone confirmed the name');
446
+ assert.match(row.textContent, /octocat/);
447
+ });
448
+
449
+ await test('an unrecognised view shape draws nothing rather than guessing', async () => {
450
+ const h = await boot({ rows: { pending: withProfile({ view: 'everything', account: { bio: 'x' } }), invited: [], dismissed: [] } });
451
+ assert.equal(h.rows()[0].querySelector('.ov-row__profile'), null,
452
+ 'a shape this page does not know is a shape it must not render — fail closed');
453
+ });
454
+
455
+ await test('an applicant own words are ESCAPED, never linkified (the render contract)', async () => {
456
+ const nasty = {
457
+ ...PUBLIC_PROFILE,
458
+ account: { ...PUBLIC_PROFILE.account, bio: '<img src=x onerror=alert(1)>ping http://evil.example' },
459
+ };
460
+ const h = await boot({ rows: { pending: withProfile(nasty), invited: [], dismissed: [] } });
461
+ const block_ = h.rows()[0].querySelector('.ov-row__profile');
462
+ assert.equal(block_.querySelectorAll('img').length, 0, 'a bio is user content on a page an Archon reads');
463
+ assert.equal(block_.querySelectorAll('a').length, 0, 'the contract says never linkify — an Archon must not be handed a one-click link a stranger wrote');
464
+ assert.match(block_.textContent, /evil\.example/, 'the text itself is still shown, just inert');
465
+ });
466
+
467
+ await test('the profile changes what the row SAYS, never what it offers (ADR 0016)', async () => {
468
+ const rich = await boot({ rows: { pending: withProfile(PUBLIC_PROFILE), invited: [], dismissed: [] } });
469
+ const bare = await boot({ rows: { pending: withProfile(null), invited: [], dismissed: [] } });
470
+ const actionsOf = (h) => [...h.rows()[0].querySelectorAll('button')].map((b) => b.textContent.trim()).sort();
471
+ assert.deepEqual(actionsOf(rich), actionsOf(bare),
472
+ 'a rich record is not a reason to admit and an empty one is not a reason to refuse — the decisions must be identical');
473
+ });
474
+
381
475
  summary();