@bongos/core 1.19.579 → 1.19.581

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 (40) hide show
  1. package/.bongos-core.json +75 -40
  2. package/bin/bongos.js +10 -1
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +4 -0
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0260-the-application-is-the-consent-and-the-echo-is-the-gate.md +146 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +73 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +21 -18
  13. package/docs/copy-registry.json +50 -23
  14. package/docs/file-map.md +2 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  17. package/modules/hall-ui/public/oversight.css +7 -0
  18. package/modules/hall-ui/public/watch.js +75 -0
  19. package/modules/onboarding/routes/access-requests.js +56 -1
  20. package/modules/platform-identity/applicant-profile.js +158 -0
  21. package/modules/platform-identity/application-echo.js +50 -0
  22. package/modules/platform-identity/recruiter-sliver.js +19 -0
  23. package/modules/platform-identity/routes/sso.js +126 -0
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/scripts/gds/build-cli-package.js +8 -2
  27. package/scripts/gds/cli-lib.js +11 -3
  28. package/scripts/gds/recall.js +1 -1
  29. package/scripts/gds/start.js +39 -26
  30. package/scripts/gds/surface.js +89 -0
  31. package/src/bongos/auth-admission.js +61 -0
  32. package/src/bongos/auth.js +2 -1
  33. package/src/module-api.js +11 -1
  34. package/tests/adr_renumber_integrity.mjs +84 -0
  35. package/tests/applicant_profile_boundary.mjs +641 -0
  36. package/tests/applicant_profile_read_bounds.mjs +95 -0
  37. package/tests/cli_surface.mjs +146 -0
  38. package/tests/module_api.mjs +1 -0
  39. package/tests/recruiter_sliver_boundary.mjs +35 -2
  40. 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
+ });
@@ -0,0 +1,146 @@
1
+ // tests/cli_surface.mjs — the CLI addresses the reader it actually has (task 1003680).
2
+ //
3
+ // Two obligations, and the second is the one that could silently break the whole methodology:
4
+ //
5
+ // 1. A terminal reader never sees output written to a model — no `[otb-card]` directive, no
6
+ // `/builder-*` slash command (those exist only inside Claude Code), no raw markdown link.
7
+ //
8
+ // 2. The AGENT shape is unchanged. The skills and the card-delivery hook relay `start.js`'s
9
+ // stdout verbatim and branch on its first character; a stray difference there breaks the card
10
+ // contract for every session. So the agent cases below assert the exact old strings.
11
+
12
+ import test from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const require = createRequire(import.meta.url);
20
+ const surface = require(path.join(REPO_ROOT, 'scripts', 'gds', 'surface.js'));
21
+
22
+ const AGENT_ENV = {};
23
+ const TERM_ENV = { BONGOS_SURFACE: 'terminal' };
24
+
25
+ // ── 1. The signal itself ────────────────────────────────────────────────────────────────────
26
+
27
+ test('agent is the default — nothing changes unless something opts out', () => {
28
+ assert.equal(surface.resolveSurface({}), 'agent');
29
+ assert.equal(surface.resolveSurface({ BONGOS_SURFACE: '' }), 'agent');
30
+ assert.equal(surface.resolveSurface({ BONGOS_SURFACE: 'nonsense' }), 'agent');
31
+ assert.equal(surface.isTerminal({}), false);
32
+ });
33
+
34
+ test('terminal is opt-in, case- and space-insensitive', () => {
35
+ assert.equal(surface.resolveSurface({ BONGOS_SURFACE: 'terminal' }), 'terminal');
36
+ assert.equal(surface.resolveSurface({ BONGOS_SURFACE: ' TERMINAL ' }), 'terminal');
37
+ assert.equal(surface.isTerminal(TERM_ENV), true);
38
+ });
39
+
40
+ test('the surface is never inferred from isTTY', () => {
41
+ // A Claude Code session's stdout is not a TTY either, so TTY-sniffing would classify every
42
+ // agent run as a terminal and break the card contract. The module must not consult it.
43
+ const src = require('node:fs').readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'surface.js'), 'utf8');
44
+ const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
45
+ assert.ok(!/isTTY/.test(code), 'surface.js must not branch on isTTY');
46
+ });
47
+
48
+ // ── 2. What each reader gets ────────────────────────────────────────────────────────────────
49
+
50
+ test('actions name a command the reader can actually run', () => {
51
+ assert.equal(surface.action('ship', 123, AGENT_ENV), '/builder-ship 123');
52
+ assert.equal(surface.action('ship', 123, TERM_ENV), 'bongos ship 123');
53
+ assert.equal(surface.action('start', null, AGENT_ENV), '/builder-start');
54
+ assert.equal(surface.action('start', null, TERM_ENV), 'bongos start');
55
+ });
56
+
57
+ test('task refs stay hall links for agents and go plain for terminals', () => {
58
+ const url = 'https://example.com/builders#/task/';
59
+ assert.equal(surface.taskRef(7, url, AGENT_ENV), '[#7](https://example.com/builders#/task/7)');
60
+ assert.equal(surface.taskRef(7, url, TERM_ENV), '#7');
61
+ });
62
+
63
+ test('emphasis is markdown for agents and bare text for terminals', () => {
64
+ assert.equal(surface.strong('hi', AGENT_ENV), '**hi**');
65
+ assert.equal(surface.strong('hi', TERM_ENV), 'hi');
66
+ assert.equal(surface.em('hi', AGENT_ENV), '_hi_');
67
+ assert.equal(surface.em('hi', TERM_ENV), 'hi');
68
+ assert.equal(surface.code('x', AGENT_ENV), '`x`');
69
+ assert.equal(surface.code('x', TERM_ENV), 'x');
70
+ });
71
+
72
+ test('the agent table is still GitHub-flavoured markdown, byte for byte', () => {
73
+ const out = surface.table(['#', 'What'], [['a', 'b']], AGENT_ENV);
74
+ assert.equal(out, '| # | What |\n|---|---|\n| a | b |');
75
+ });
76
+
77
+ test('the terminal table aligns into columns with no pipe soup', () => {
78
+ const out = surface.table(['#', 'What'], [['1', 'short'], ['22', 'a longer one']], TERM_ENV);
79
+ const lines = out.split('\n');
80
+ assert.ok(!out.includes('|'), `no pipes in a terminal table:\n${out}`);
81
+ assert.ok(!/\|---/.test(out), 'no markdown separator row');
82
+ assert.match(lines[1], /^─+ {2}─+$/, 'a rule under the header');
83
+ // Column two must start at the same offset on every row.
84
+ const col2 = lines.filter((_, i) => i !== 1).map((l) => l.indexOf(l.trim().split(/\s{2,}/)[1]));
85
+ assert.equal(new Set(col2).size, 1, `column 2 misaligned:\n${out}`);
86
+ });
87
+
88
+ test('no trailing whitespace on a terminal row', () => {
89
+ const out = surface.table(['a', 'bbbb'], [['x', 'y']], TERM_ENV);
90
+ for (const line of out.split('\n')) assert.ok(!/\s$/.test(line), `trailing space: ${JSON.stringify(line)}`);
91
+ });
92
+
93
+ // ── 3. The call sites ───────────────────────────────────────────────────────────────────────
94
+
95
+ test('start.js routes every reader-facing idiom through the surface', () => {
96
+ const src = require('node:fs').readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'start.js'), 'utf8');
97
+ const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
98
+
99
+ // Two exemptions, both agent-only BY CONSTRUCTION:
100
+ // • CARD_DIRECTIVE describes the card TO the model, so naming the slash command is correct.
101
+ // • the `--widget` HTML renderer only ever runs for a session with show_widget; its markup
102
+ // is identifiable by hesc()/class="bs-" and never reaches a terminal.
103
+ const scanned = code
104
+ .replace(/const CARD_DIRECTIVE = \{[\s\S]*?\};/, '')
105
+ .split('\n')
106
+ .filter((l) => !l.includes('hesc(') && !l.includes('class="bs-'))
107
+ .join('\n');
108
+ const slashes = [...scanned.matchAll(/\/builder-[a-z-]+/g)].map((m) => m[0]);
109
+ assert.deepEqual(slashes, [], `hard-coded slash commands left in start.js: ${slashes.join(', ')}`);
110
+
111
+ // A hall link built by hand would bypass the switch.
112
+ const rawRefs = [...code.matchAll(/\[#\$\{[^}]+\}\]\(/g)].map((m) => m[0]);
113
+ assert.deepEqual(rawRefs, [], 'hand-built markdown task links left in start.js');
114
+ });
115
+
116
+ test('the card directive is suppressed for a terminal reader', () => {
117
+ const src = require('node:fs').readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'start.js'), 'utf8');
118
+ assert.match(
119
+ src,
120
+ /function emitCardDirective\(form\) \{[\s\S]{0,400}?surface\.isTerminal\(\)\) return;/,
121
+ 'emitCardDirective must return early on a terminal surface',
122
+ );
123
+ });
124
+
125
+ test('only the dispatchers declare the terminal surface', () => {
126
+ const fs = require('node:fs');
127
+ const dispatcher = fs.readFileSync(path.join(REPO_ROOT, 'bin', 'bongos.js'), 'utf8');
128
+ assert.match(dispatcher, /BONGOS_SURFACE: 'terminal'/, 'bin/bongos.js must mark verb spawns as terminal');
129
+
130
+ // `exec` is the agent's escape hatch into a core script and must NOT be marked.
131
+ const execLine = dispatcher.split('\n').find((l) => l.includes('args.slice(2)') && l.includes('spawnSync'));
132
+ assert.ok(execLine, 'could not find the exec spawn');
133
+ assert.ok(!execLine.includes('BONGOS_SURFACE'), '`bongos exec` must stay on the agent surface');
134
+
135
+ const builder = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'build-cli-package.js'), 'utf8');
136
+ assert.match(builder, /BONGOS_SURFACE: 'terminal'/, 'the generated dispatcher must mark spawns as terminal');
137
+ assert.match(builder, /'scripts\/gds\/surface\.js'/, 'surface.js must ship in the package');
138
+ });
139
+
140
+ test('the skills still invoke the scripts directly, which is what keeps agents on the agent surface', () => {
141
+ // If a skill ever routed through `bongos <verb>`, it would silently inherit the terminal
142
+ // surface and the card contract would break with no test failing anywhere else.
143
+ const fs = require('node:fs');
144
+ const skill = fs.readFileSync(path.join(REPO_ROOT, '.claude', 'skills', 'builder-start', 'SKILL.md'), 'utf8');
145
+ assert.match(skill, /node scripts\/gds\/start\.js/, 'builder-start must call the script directly');
146
+ });
@@ -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',
@@ -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();