@bongos/core 1.19.562 → 1.19.564

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 (30) hide show
  1. package/.bongos-core.json +61 -31
  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 +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0251-a-suggestion-of-who-to-invite-is-a-recruiting-surface-and-owes-the-opt-out.md +79 -0
  8. package/docs/adr/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md +109 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +69 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +127 -127
  13. package/docs/copy-registry.json +162 -162
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  16. package/modules/lifecycle/db-versions.js +22 -0
  17. package/modules/lifecycle/db.js +2 -1
  18. package/modules/lifecycle/routes/version-route-authz.js +56 -0
  19. package/modules/lifecycle/routes/versions.js +22 -0
  20. package/modules/platform-identity/invite-suggestions.js +57 -0
  21. package/modules/platform-identity/routes/sso.js +8 -0
  22. package/modules/provisioning/routes/provisioning.js +56 -0
  23. package/modules/public-landing/public/assets/cosmos.css +16 -0
  24. package/modules/public-landing/public/projects.html +85 -3
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/src/module-api.js +1 -1
  28. package/tests/one_planning_version.mjs +161 -0
  29. package/tests/project_invite_suggestions.mjs +133 -0
  30. package/tests/project_invite_ui.mjs +171 -18
@@ -0,0 +1,161 @@
1
+ // tests/one_planning_version.mjs — exactly one version plans at a time
2
+ // (BV1.R04, task 1003592, goal 1000086, ADR 0250 D5).
3
+ //
4
+ // WHY THIS EXISTS AT ALL. On its own the rule looks like housekeeping. It is the
5
+ // second half of R03: once a building version's goal set is closed, the first
6
+ // thing a refused caller reaches for is a NEW version to put the goal on — and
7
+ // the scope gate becomes decorative. One planning version means one queue for
8
+ // future work and no parallel escape hatch.
9
+ //
10
+ // Scope, stated so the next reader does not think it was forgotten: this guards
11
+ // the PLANNING slot only. Creating a `shipped` or `frozen` version is archival
12
+ // bookkeeping. Refusing a second BUILDING version is D5's other half and belongs
13
+ // to task 1003606 (R19).
14
+ //
15
+ // The rule is a pure predicate (`authorizeVersionCreate`, the `authorizeGoalCreate`
16
+ // precedent), so it is EXERCISED here rather than string-matched — including the
17
+ // shapes a live-Postgres test would never set up. The route's own write path stays
18
+ // covered by versions_route.mjs against real Postgres; what is checked of the route
19
+ // here is only the wiring, which is all the route now contains.
20
+ //
21
+ // Run: node --test tests/one_planning_version.mjs
22
+
23
+ import assert from 'node:assert/strict';
24
+ import { test } from 'node:test';
25
+ import { readFileSync } from 'node:fs';
26
+ import { createRequire } from 'node:module';
27
+ import { makeSqlAwareClient } from './helpers.mjs';
28
+
29
+ process.env.NODE_ENV = 'test';
30
+ const require = createRequire(import.meta.url);
31
+ const { versionsWithStatus } = require('../modules/lifecycle/db.js');
32
+ const { authorizeVersionCreate } = require('../modules/lifecycle/routes/version-route-authz.js');
33
+
34
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
35
+ const V2 = { id: 'BONGOS-V2', name: 'Next' };
36
+ const V3 = { id: 'BONGOS-V3', name: 'After' };
37
+
38
+ function fakePool(rows) {
39
+ const c = makeSqlAwareClient(() => ({ rows }));
40
+ return { query: (sql, p) => c._client.query(sql, p), connect: c.connect, queries: c._client.queries };
41
+ }
42
+
43
+ // ---- the decision, exercised ------------------------------------------------
44
+
45
+ test('the planning slot is free → allowed', () => {
46
+ assert.deepEqual(authorizeVersionCreate({ status: 'planning', planning: [] }), { ok: true });
47
+ });
48
+
49
+ test('the planning slot is taken → named 409 that says which version has it', () => {
50
+ const d = authorizeVersionCreate({ status: 'planning', planning: [V2] });
51
+ assert.equal(d.ok, false);
52
+ assert.equal(d.status, 409);
53
+ assert.equal(d.body.error, 'planning_version_exists');
54
+ assert.match(d.body.message, /BONGOS-V2/,
55
+ 'a caller who has to go look up which version is one step from cutting one');
56
+ assert.deepEqual(d.body.planning, [V2], 'and the rows travel with the refusal');
57
+ });
58
+
59
+ test('creating a shipped, frozen or building version is untouched', () => {
60
+ // Archival bookkeeping must not be caught by a rule about the future queue,
61
+ // even while a planning version exists.
62
+ for (const status of ['shipped', 'frozen', 'building']) {
63
+ assert.deepEqual(authorizeVersionCreate({ status, planning: [V2] }), { ok: true },
64
+ `'${status}' is not the planning slot`);
65
+ }
66
+ });
67
+
68
+ test('more than one planning version already present → still refuses, names the first', () => {
69
+ // The state this rule exists to prevent may already exist (the cutover is R28).
70
+ // The gate must not throw or wave it through on the way to fixing it.
71
+ const d = authorizeVersionCreate({ status: 'planning', planning: [V2, V3] });
72
+ assert.equal(d.body.error, 'planning_version_exists');
73
+ assert.equal(d.body.planning.length, 2, 'the caller sees the full mess, not a sample');
74
+ });
75
+
76
+ test('a missing or malformed planning list does not crash the gate', () => {
77
+ assert.deepEqual(authorizeVersionCreate({ status: 'planning' }), { ok: true },
78
+ 'omitted defaults to empty — the route only reads when status is planning');
79
+ assert.deepEqual(authorizeVersionCreate({ status: 'planning', planning: null }), { ok: true });
80
+ assert.deepEqual(authorizeVersionCreate({ status: 'planning', planning: [null, undefined] }), { ok: true },
81
+ 'empty-after-filtering is empty; a falsy row must not become a phantom refusal');
82
+ });
83
+
84
+ test('the refusal is data, not a rendered string the route has to parse', () => {
85
+ const d = authorizeVersionCreate({ status: 'planning', planning: [V2] });
86
+ assert.deepEqual(Object.keys(d).sort(), ['body', 'ok', 'status']);
87
+ assert.ok(typeof d.body.error === 'string' && typeof d.status === 'number');
88
+ });
89
+
90
+ // ---- the read ---------------------------------------------------------------
91
+
92
+ test('versionsWithStatus binds the status and returns id + name only', async () => {
93
+ const pool = fakePool([V2]);
94
+ const out = await versionsWithStatus('planning', { pool });
95
+ assert.deepEqual(out, [V2]);
96
+ assert.match(String(pool.queries[0].sql), /SELECT id, name FROM versions WHERE status = \$1/);
97
+ assert.deepEqual(pool.queries[0].params, ['planning'], 'the status is bound, not interpolated');
98
+ });
99
+
100
+ test('no planning version reads as an empty list, not a null', async () => {
101
+ assert.deepEqual(await versionsWithStatus('planning', { pool: fakePool([]) }), [],
102
+ 'and the gate treats empty as allowed, closing the loop between read and decision');
103
+ });
104
+
105
+ // ---- the wiring, which is all the route now carries -------------------------
106
+
107
+ const ROUTE = (() => {
108
+ const s = src('modules/lifecycle/routes/versions.js');
109
+ const a = s.indexOf("router.post('/versions'");
110
+ assert.notEqual(a, -1, 'POST /versions not found');
111
+ const b = s.indexOf('router.', a + 20);
112
+ return s.slice(a, b === -1 ? s.length : b);
113
+ })();
114
+
115
+ test('the route renders the decision and does not carry the rule', () => {
116
+ assert.match(ROUTE, /const versionDecision = authorizeVersionCreate\(\{/);
117
+ assert.match(ROUTE, /return res\.status\(versionDecision\.status\)\.json\(versionDecision\.body\);/);
118
+ assert.equal(/planning_version_exists/.test(ROUTE), false,
119
+ 'the refusal is the gate\'s to name, not a second copy in the handler');
120
+ });
121
+
122
+ test('the route reads the planning versions ONLY when it could matter', () => {
123
+ // Creating a shipped version must not pay for a query about the planning queue.
124
+ assert.match(ROUTE, /status === 'planning' \? await db\.versionsWithStatus\('planning'\) : \[\]/);
125
+ });
126
+
127
+ test('the gate runs AFTER validation and BEFORE the insert', () => {
128
+ const badStatus = ROUTE.indexOf("'bad_status'");
129
+ const exists = ROUTE.indexOf("'version_exists'");
130
+ const gate = ROUTE.indexOf('authorizeVersionCreate');
131
+ const write = ROUTE.indexOf('db.createVersion');
132
+ assert.ok(badStatus !== -1 && exists !== -1 && gate !== -1 && write !== -1);
133
+ assert.ok(badStatus < gate, 'an invalid status is its own answer, not a queue conflict');
134
+ assert.ok(exists < gate, 'a duplicate id keeps its own answer too');
135
+ assert.ok(gate < write, 'a refusal after the insert is not a refusal');
136
+ });
137
+
138
+ test('the default status is planning, so an unqualified create hits the gate', () => {
139
+ // The body may omit `status`. If that default ever changed, the gate would
140
+ // silently stop applying to the most common call.
141
+ assert.match(ROUTE, /const status = body\.status \|\| 'planning';/);
142
+ });
143
+
144
+ test('the accepted race is described as unmitigated, because it is', () => {
145
+ // An earlier draft of this comment said the race was "left to a partial unique
146
+ // index" — which reads as though one exists. It does not, and a future reader
147
+ // taking that at face value would believe two concurrent creates cannot both
148
+ // slip through. Pinned so the honest wording cannot quietly regress.
149
+ assert.match(ROUTE, /RACE, ACCEPTED AND UNMITIGATED/);
150
+ assert.match(ROUTE, /no database\s*\n?\s*\/\/ constraint backs it/,
151
+ 'the comment must say plainly that nothing catches it');
152
+ });
153
+
154
+ // ---- the rule is one half of a pair ----------------------------------------
155
+
156
+ test('R03 and R04 are the pair: closing the goal set, and closing the way around it', () => {
157
+ assert.match(src('modules/lifecycle/routes/goal-route-authz.js'), /function authorizeGoalCreate/,
158
+ 'R03 closes a building version\'s goal set');
159
+ assert.equal(authorizeVersionCreate({ status: 'planning', planning: [V2] }).ok, false,
160
+ 'and R04 closes the obvious route around it — neither is much use alone');
161
+ });
@@ -0,0 +1,133 @@
1
+ // tests/project_invite_suggestions.mjs — WHO the creation wizard suggests
2
+ // inviting (BV1.R28, task 1002347, goal 1000046 criterion C6, ADR 0251).
3
+ //
4
+ // tests/project_invite_ui.mjs pins the SURFACE (how a suggestion renders and what
5
+ // clicking it sends). This file pins the LOOKUP behind it, and above all the two
6
+ // properties that are security rather than behaviour:
7
+ //
8
+ // • it reads the RECRUITING DIRECTORY, not the leaderboard — so the recruiting
9
+ // opt-out is honoured. The two readers differ by exactly one SQL predicate
10
+ // and the wrong one would hand an owner the name of every builder who asked
11
+ // not to be recruited (community spec D3; see scoutingListableSql).
12
+ // • `github_id` never leaves the module — the invite route takes a LOGIN, and
13
+ // the id names someone who has consented to nothing.
14
+ //
15
+ // Run: node --test --test-reporter=tap tests/project_invite_suggestions.mjs
16
+
17
+ import test from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+
20
+ // ESM named import off the CJS module: knip cannot trace a createRequire()
21
+ // import, so a test-only export would read as dead code and move the ratchet.
22
+ import { listInviteSuggestions } from '../modules/platform-identity/invite-suggestions.js';
23
+
24
+ // A pool that records the SQL it was handed and replays canned rows.
25
+ function fakePool(rows) {
26
+ const seen = [];
27
+ return {
28
+ seen,
29
+ query: async (text, params) => {
30
+ seen.push({ text, params });
31
+ return { rows };
32
+ },
33
+ };
34
+ }
35
+
36
+ const BUILDERS = [
37
+ { github_id: 1, github_login: 'ada', display_name: 'Ada L', avatar_url: 'a.png', disciplines: ['engineer'], project_count: 3, total_tasks_shipped: 40, total_credits: 900 },
38
+ { github_id: 2, github_login: 'grace', display_name: 'Grace H', avatar_url: 'g.png', disciplines: ['engineer'], project_count: 2, total_tasks_shipped: 30, total_credits: 800 },
39
+ { github_id: 3, github_login: 'linus', display_name: null, avatar_url: null, disciplines: null, project_count: 1, total_tasks_shipped: 10, total_credits: 100 },
40
+ ];
41
+
42
+ test('it reads the RECRUITING directory — the consent predicate is in the query it runs', async () => {
43
+ // Arrange
44
+ const pool = fakePool(BUILDERS);
45
+
46
+ // Act
47
+ await listInviteSuggestions({ limit: 3, pool });
48
+
49
+ // Assert — the opt-out axis (`recruiter_reach`/opt-out columns via
50
+ // scoutingListableSql) must appear in the SQL. A leaderboard read would join
51
+ // rollupVisibleSql instead and quietly include opted-out builders.
52
+ const sql = pool.seen[0].text;
53
+ assert.match(sql, /platform_identity_builder_project_activity/, 'the cross-project activity rollup, not the credits ledger');
54
+ assert.match(sql, /platform_identity_accounts/, 'joined to accounts for the consent predicate');
55
+ assert.doesNotMatch(sql, /FROM\s+builders\b/, 'never the instance leaderboard table');
56
+ });
57
+
58
+ test('github_id never crosses the boundary — the caller gets the login, which is what an invite takes', async () => {
59
+ // Act
60
+ const out = await listInviteSuggestions({ limit: 3, pool: fakePool(BUILDERS) });
61
+
62
+ // Assert
63
+ assert.equal(out.length, 3);
64
+ for (const row of out) {
65
+ assert.ok(!('github_id' in row), 'no github_id on a suggestion');
66
+ assert.ok(row.github_login, 'the login IS the address the invite route takes');
67
+ }
68
+ assert.deepEqual(Object.keys(out[0]).sort(),
69
+ ['avatar_url', 'disciplines', 'display_name', 'github_login', 'project_count', 'total_tasks_shipped'],
70
+ 'an explicit whitelist, so a new column on the rollup cannot leak by default');
71
+ });
72
+
73
+ test('the owner is dropped from their own suggestions, and the list still fills', async () => {
74
+ // Act — ada (1) is the owner asking
75
+ const out = await listInviteSuggestions({ limit: 2, exclude: [1], pool: fakePool(BUILDERS) });
76
+
77
+ // Assert
78
+ assert.deepEqual(out.map((r) => r.github_login), ['grace', 'linus'],
79
+ 'the owner is gone and the next builder moved up — the limit is still met');
80
+ });
81
+
82
+ test('the over-read is sized to the exclusion, so filtering can never return short', async () => {
83
+ // Arrange
84
+ const pool = fakePool(BUILDERS);
85
+
86
+ // Act
87
+ await listInviteSuggestions({ limit: 3, exclude: [1, 2], pool });
88
+
89
+ // Assert
90
+ assert.equal(pool.seen[0].params[0], 5, 'asked for limit + the two it may have to drop');
91
+ });
92
+
93
+ test('a limit of zero asks the database nothing at all', async () => {
94
+ // Arrange
95
+ const pool = fakePool(BUILDERS);
96
+
97
+ // Act
98
+ const out = await listInviteSuggestions({ limit: 0, pool });
99
+
100
+ // Assert
101
+ assert.deepEqual(out, []);
102
+ assert.equal(pool.seen.length, 0, 'no seek, no query — the solo answer costs nothing');
103
+ });
104
+
105
+ test('the limit is clamped, so a caller cannot turn a suggestion into a directory dump', async () => {
106
+ // Arrange
107
+ const pool = fakePool(BUILDERS);
108
+
109
+ // Act
110
+ await listInviteSuggestions({ limit: 9999, pool });
111
+
112
+ // Assert
113
+ assert.equal(pool.seen[0].params[0], 25, 'clamped to the ceiling');
114
+ });
115
+
116
+ test('a row with no login is dropped — it cannot be invited, so it is not a suggestion', async () => {
117
+ // Act
118
+ const out = await listInviteSuggestions({
119
+ limit: 5,
120
+ pool: fakePool([{ github_id: 9, github_login: null, display_name: 'Ghost' }, ...BUILDERS]),
121
+ });
122
+
123
+ // Assert
124
+ assert.deepEqual(out.map((r) => r.github_login), ['ada', 'grace', 'linus']);
125
+ });
126
+
127
+ test('a missing disciplines array reads as empty rather than null', async () => {
128
+ // Act
129
+ const out = await listInviteSuggestions({ limit: 5, pool: fakePool(BUILDERS) });
130
+
131
+ // Assert — linus has disciplines: null on the row
132
+ assert.deepEqual(out[2].disciplines, [], 'the surface can map() it without a guard');
133
+ });
@@ -48,7 +48,8 @@ const END = SCRIPT.indexOf('/* ── boot: who am I');
48
48
  assert.ok(START > 0, 'the invite region must start at INVITE_MOUNTS');
49
49
  assert.ok(END > START, 'the invite region must end before the boot block');
50
50
  const REGION = SCRIPT.slice(START, END);
51
- for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function skipInvite(', 'function inviteRefusal(']) {
51
+ for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function skipInvite(', 'function inviteRefusal(',
52
+ 'function setInviteSuggestions(', 'function renderInviteSugg(', 'function loadInviteSuggestions(']) {
52
53
  assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
53
54
  }
54
55
 
@@ -59,24 +60,43 @@ for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function ski
59
60
  const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><\/li>/g;
60
61
  const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2] }));
61
62
 
63
+ // A suggestion row: whatever the row NAMES, plus the login its button carries.
64
+ // Parsed off the button so the test reads the address the click will actually
65
+ // send, not the label a row happened to render.
66
+ const SUGG = /<li><span class="suggWho">(.*?)<\/span><button [^>]*data-invite-pick="([^"]*)"[^>]*>/g;
67
+ const parseSugg = (html) => [...String(html).matchAll(SUGG)].map((m) => ({ named: m[1], login: m[2] }));
68
+
62
69
  function boot({ mount = 'wizInvite', answers = [] } = {}) {
63
70
  const requests = [];
64
71
  const els = new Map();
65
72
  const listeners = new Map(); // "elId:type" → [fn]
66
73
  const focused = [];
67
74
 
68
- const makeEl = (id) => ({
69
- _id: id, _html: '', textContent: '', className: '', value: '', hidden: false,
70
- set innerHTML(v) { this._html = String(v); },
71
- get innerHTML() { return this._html; },
72
- addEventListener(type, fn) {
73
- const key = id + ':' + type;
74
- if (!listeners.has(key)) listeners.set(key, []);
75
- listeners.get(key).push(fn);
76
- },
77
- setAttribute() {},
78
- focus() { focused.push(id); },
79
- });
75
+ const makeEl = (id) => {
76
+ const classes = new Set();
77
+ return {
78
+ _id: id, _html: '', textContent: '', className: '', value: '', hidden: false,
79
+ _classes: classes,
80
+ set innerHTML(v) { this._html = String(v); },
81
+ get innerHTML() { return this._html; },
82
+ // task 1002347 (R28): the suggestion surface shows its reason line the
83
+ // .modWhy way — a class toggle, not a hidden flag — so the stub grows the
84
+ // one DOM affordance that answer needs.
85
+ classList: {
86
+ toggle(name, on) { if (on) classes.add(name); else classes.delete(name); },
87
+ add(name) { classes.add(name); },
88
+ remove(name) { classes.delete(name); },
89
+ contains(name) { return classes.has(name); },
90
+ },
91
+ addEventListener(type, fn) {
92
+ const key = id + ':' + type;
93
+ if (!listeners.has(key)) listeners.set(key, []);
94
+ listeners.get(key).push(fn);
95
+ },
96
+ setAttribute() {},
97
+ focus() { focused.push(id); },
98
+ };
99
+ };
80
100
  const $ = (id) => {
81
101
  if (id == null) return null;
82
102
  if (!els.has(id)) els.set(id, makeEl(id));
@@ -86,7 +106,9 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
86
106
  // Every mount's elements exist before the region wires them, exactly as the
87
107
  // real page has them: the script runs at the end of <body>.
88
108
  ['wizInvite', 'wizInviteWho', 'wizInviteList', 'wizInviteMsg', 'wizInviteLater', 'doneStepsHead',
89
- 'mInvite', 'mInviteWho', 'mInviteList', 'mInviteMsg'].forEach($);
109
+ 'wizInviteWhy', 'wizInviteSugg',
110
+ 'mInvite', 'mInviteWho', 'mInviteList', 'mInviteMsg',
111
+ 'mInviteWhy', 'mInviteSugg'].forEach($);
90
112
 
91
113
  const queued = answers.slice();
92
114
  const fetch = (url, init = {}) => {
@@ -113,19 +135,20 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
113
135
  vm.runInContext(REGION, sandbox, { filename: 'projects.html#project-invite' });
114
136
 
115
137
  const mounts = {
116
- wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg' },
117
- mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg' },
138
+ wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', why: 'wizInviteWhy', sugg: 'wizInviteSugg' },
139
+ mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', why: 'mInviteWhy', sugg: 'mInviteSugg' },
118
140
  };
119
141
  const m = mounts[mount];
120
142
  const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
121
143
 
144
+ let pickLogin = '';
122
145
  const press = async (marker) => {
123
146
  const handlers = listeners.get(mount + ':click') || [];
124
147
  assert.equal(handlers.length, 1, 'the mount wires exactly one delegated click handler');
125
148
  handlers[0]({
126
149
  target: {
127
- closest: (sel) => (sel === '[data-invite-send],[data-invite-skip]'
128
- ? { hasAttribute: (a) => a === marker }
150
+ closest: (sel) => (sel === '[data-invite-send],[data-invite-skip],[data-invite-pick]'
151
+ ? { hasAttribute: (a) => a === marker, getAttribute: () => pickLogin }
129
152
  : null),
130
153
  },
131
154
  });
@@ -142,6 +165,13 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
142
165
  async type(login) { $(m.who).value = login; },
143
166
  async send() { await press('data-invite-send'); },
144
167
  async skip() { await press('data-invite-skip'); },
168
+ // ── the suggestion half (task 1002347, BV1.R28) ──────────────────────
169
+ why: () => $(m.why),
170
+ whyOn: () => $(m.why)._classes.has('on'),
171
+ suggested: () => parseSugg($(m.sugg).innerHTML),
172
+ async suggest(payload) { sandbox.setInviteSuggestions(mount, payload); await settle(); },
173
+ async load(instanceId) { sandbox.loadInviteSuggestions(mount, instanceId); await settle(); },
174
+ async pick(login) { pickLogin = login; await press('data-invite-pick'); },
145
175
  async enter() {
146
176
  const handlers = listeners.get(m.who + ':keydown') || [];
147
177
  assert.equal(handlers.length, 1, 'the field takes Enter');
@@ -500,3 +530,126 @@ test('the manage view has a rendered state for the invite line', () => {
500
530
  assert.ok(states['in-manage-invite'], 'the section has a kit state');
501
531
  assert.deepEqual(states['in-manage-invite'].expect.visible, ['#mInvite', '#mInvite h2', '#mInviteWho', '#mInvite [data-invite-send]']);
502
532
  });
533
+
534
+ // ── who is worth inviting (task 1002347, BV1.R28, ADR 0251) ──────────────────
535
+ // The engine states WHAT to look for and never who (ADR 0239 §6); these are the
536
+ // names, and the rules they must not break: the reason is always said, "nobody"
537
+ // is an answer rather than a blank, and a picked name takes the SAME write path
538
+ // as a typed one.
539
+
540
+ test('a suggestion list renders its names, and each row carries the login its button will send', async () => {
541
+ const h = boot();
542
+ await h.open('mercury');
543
+ await h.suggest({
544
+ suggested: [
545
+ { github_login: 'ada', display_name: 'Ada L', total_tasks_shipped: 12, project_count: 3 },
546
+ { github_login: 'grace', display_name: null, total_tasks_shipped: 0, project_count: 0 },
547
+ ],
548
+ seeking: { kind: 'top-builders', limit: 3 },
549
+ reason: 'A small team is the shape that fills fastest.',
550
+ });
551
+ assert.deepEqual(h.suggested().map((r) => r.login), ['ada', 'grace']);
552
+ assert.match(h.suggested()[0].named, /Ada L/, 'a display name is used when the row carries one');
553
+ assert.match(h.suggested()[0].named, /@ada/, 'and the login is still shown — it is the address');
554
+ assert.match(h.suggested()[1].named, /<b>@grace<\/b>/, 'with no display name the login stands alone');
555
+ });
556
+
557
+ test('the reason is ALWAYS said — including when the answer is nobody', async () => {
558
+ const h = boot();
559
+ await h.open('mercury');
560
+ await h.suggest({ suggested: [], seeking: null, reason: 'You said you are building alone, so nobody is suggested.' });
561
+ assert.equal(h.suggested().length, 0, 'no names');
562
+ assert.equal(h.why().textContent, 'You said you are building alone, so nobody is suggested.');
563
+ assert.ok(h.whyOn(), 'and the line is shown — "nobody" is an answer, not a blank state');
564
+ });
565
+
566
+ test('no payload at all says nothing — a failed lookup must not invent a reason', async () => {
567
+ const h = boot();
568
+ await h.open('mercury');
569
+ await h.suggest(null);
570
+ assert.equal(h.suggested().length, 0);
571
+ assert.equal(h.why().textContent, '');
572
+ assert.ok(!h.whyOn(), 'the reason line is off entirely, not empty-but-present');
573
+ });
574
+
575
+ test('picking a suggestion sends exactly what typing that name would have sent', async () => {
576
+ const h = boot();
577
+ await h.open('mercury');
578
+ await h.suggest({ suggested: [{ github_login: 'ada' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' });
579
+ await h.pick('ada');
580
+ const posts = h.requests.filter((r) => r.method === 'POST');
581
+ assert.equal(posts.length, 1, 'one invite, through the one write path');
582
+ assert.deepEqual(JSON.parse(posts[0].body), { client_id: 'mercury', github_login: 'ada' },
583
+ 'the same body shape as a typed invite — the slug IS the client_id');
584
+ });
585
+
586
+ test('a name invited this session stops being suggested — the pending line is its receipt', async () => {
587
+ const h = boot();
588
+ await h.open('mercury');
589
+ await h.suggest({ suggested: [{ github_login: 'ada' }, { github_login: 'grace' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' });
590
+ assert.equal(h.suggested().length, 2);
591
+ await h.pick('ada');
592
+ assert.deepEqual(h.suggested().map((r) => r.login), ['grace'], 'ada moved to the pending list and left the suggestions');
593
+ assert.deepEqual(h.rows().map((r) => r.login), ['ada'], 'and she is on the pending list exactly once');
594
+ });
595
+
596
+ test('switching the mount to another project drops the suggestions with the pending rows', async () => {
597
+ const h = boot();
598
+ await h.open('mercury');
599
+ await h.suggest({ suggested: [{ github_login: 'ada' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' });
600
+ await h.open('venus');
601
+ assert.equal(h.suggested().length, 0, 'the names belonged to the project they were suggested for');
602
+ assert.ok(!h.whyOn(), 'and so did the reason');
603
+ });
604
+
605
+ test('the suggestions read is own-scoped by ADDRESS: it asks the instance route, with the session', async () => {
606
+ const h = boot({ answers: [{ ok: true, status: 200, body: { suggested: [{ github_login: 'ada' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' } }] });
607
+ await h.open('mercury');
608
+ await h.load(41);
609
+ const get = h.requests.find((r) => r.method === 'GET');
610
+ assert.equal(get.url, '/api/bongos/provisioning/instances/41/invite-suggestions');
611
+ assert.equal(get.credentials, 'same-origin', 'the cookie is the whole gate — the route 404s for anyone else');
612
+ assert.deepEqual(h.suggested().map((r) => r.login), ['ada']);
613
+ });
614
+
615
+ test('no instance id asks nothing at all', async () => {
616
+ const h = boot();
617
+ await h.open('mercury');
618
+ await h.load('');
619
+ assert.equal(h.requests.filter((r) => r.method === 'GET').length, 0, 'no project, no question');
620
+ });
621
+
622
+ test('a refused or unreachable lookup is SILENT — a created project never looks broken over an extra', async () => {
623
+ for (const answer of [{ ok: false, status: 404, body: {} }, { reject: true }]) {
624
+ const h = boot({ answers: [answer] });
625
+ await h.open('mercury');
626
+ await h.load(41);
627
+ assert.equal(h.suggested().length, 0, 'no names');
628
+ assert.equal(h.msg().textContent, '', 'and NOT a word in the invite message line');
629
+ }
630
+ });
631
+
632
+ test('a suggestion never renders a github_id, and its name goes through the escaper', async () => {
633
+ const h = boot();
634
+ await h.open('mercury');
635
+ await h.suggest({
636
+ suggested: [{ github_login: 'x"><img src=x onerror=1>', display_name: '<b>bad</b>', github_id: 4242, total_tasks_shipped: 1 }],
637
+ seeking: { kind: 'top-builders', limit: 3 },
638
+ reason: 'r',
639
+ });
640
+ const html = h.sandbox.$('wizInviteSugg').innerHTML;
641
+ assert.doesNotMatch(html, /4242/, 'the id names someone who has accepted nothing');
642
+ assert.doesNotMatch(html, /<img/, 'the login is escaped inside the attribute it rides in');
643
+ assert.doesNotMatch(html, /<b>bad<\/b>/, 'and the display name is escaped in the body');
644
+ });
645
+
646
+ test('the suggestion surface is mounted on BOTH the wizard and the project page', () => {
647
+ assert.match(HUB, /<ul class="suggList" id="wizInviteSugg"><\/ul>/, 'the wizard mount');
648
+ assert.match(HUB, /<ul class="suggList" id="mInviteSugg"><\/ul>/, 'and the project page mount');
649
+ assert.match(SCRIPT, /loadInviteSuggestions\('wizInvite', \(me && created && created\.slug\) \? created\.id : ''\)/,
650
+ 'the wizard asks keyed on the SAME created record the slug comes from (ADR 0249 §1)');
651
+ assert.match(SCRIPT, /loadInviteSuggestions\('mInvite', inst\.slug \? inst\.id : ''\)/,
652
+ 'and the manage page asks with the id whose read already proved ownership');
653
+ assert.match(COSMOS, /\.suggList\{/, 'styled in cosmos.css like its sibling list');
654
+ assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>') + 1), /suggList/, 'not in the page head');
655
+ });