@bongos/core 1.19.512 → 1.19.514

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 (42) hide show
  1. package/.bongos-core.json +77 -42
  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/0182-project-visibility-two-axes.md +2 -0
  8. package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +41 -4
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +36 -35
  13. package/docs/copy-registry.json +93 -74
  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/hall-ui/public/hall-kit.js +309 -0
  17. package/modules/hall-ui/public/hall-render.js +10 -38
  18. package/modules/hall-ui/public/profile-charts.js +58 -65
  19. package/modules/hall-ui/public/profile.css +9 -18
  20. package/modules/hall-ui/public/style.css +101 -15
  21. package/modules/hall-ui/public/watch.css +6 -14
  22. package/modules/hall-ui/public/watch.html +1 -1
  23. package/modules/hall-ui/public/watch.js +139 -54
  24. package/modules/platform-identity/platform-identity.js +24 -20
  25. package/modules/platform-identity/projects-feed.js +142 -0
  26. package/modules/platform-identity/routes/projects.js +27 -8
  27. package/modules/platform-identity/tests/platform-identity.mjs +9 -5
  28. package/modules/provisioning/recommendations.js +260 -0
  29. package/modules/provisioning/routes/provisioning.js +29 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/src/module-api.js +1 -1
  33. package/tests/hall_audit.mjs +84 -0
  34. package/tests/hall_kit_charts.mjs +328 -0
  35. package/tests/hall_profile_world.mjs +27 -6
  36. package/tests/helpers.mjs +161 -0
  37. package/tests/joinability_invite_only.mjs +10 -2
  38. package/tests/projects_hub.mjs +5 -1
  39. package/tests/provisioning_recommendations.mjs +448 -0
  40. package/tests/task_detail_ui.mjs +1 -153
  41. package/tests/visible_projects_feed.mjs +320 -0
  42. package/tests/watch_applications_queue.mjs +381 -0
@@ -0,0 +1,381 @@
1
+ // tests/watch_applications_queue.mjs — the owner's applications review queue,
2
+ // EXECUTED (task 1002332, BV1.R13 of goal 1000046, criterion proj-join-policy).
3
+ //
4
+ // An application to a project IS an `access_requests` row on that project's own
5
+ // instance (ADR 0182 §Scope routes R13 here; the admin-console spec D2 settled
6
+ // the storage). The route side has carried the whole lifecycle since task
7
+ // 1002284 — approve, decline, RESCIND, idempotence — and `tests/application_
8
+ // lifecycle.mjs` executes it. What had no test, and largely no UI, was the half
9
+ // an owner actually touches:
10
+ //
11
+ // • ADR 0201, Consequences: "the queue gains a two-class row: vouched … and
12
+ // unvouched … The owner must be able to see which is which — an unvouched
13
+ // row that *looked* vouched is the whole vulnerability, so the distinction
14
+ // is UI-load-bearing, not decoration." POST /access-requests is the one
15
+ // PUBLIC unauthenticated write, so anyone may apply under anyone's name.
16
+ // • ADR 0208: "There is no UI path to the rescind. watch.js fetches only
17
+ // ?status=pending, so the invited history is not rendered and nothing offers
18
+ // the action." An approval given by mistake must be revocable before its
19
+ // holder ever signs in.
20
+ //
21
+ // Both are assertions about what a human sees and clicks, so this runs the real
22
+ // watch.js inside `vm` against the real watch.html — the parsing DOM stub from
23
+ // tests/task_detail_ui.mjs (task 1003516), now shared out of helpers.mjs. A
24
+ // render that throws, a button wired to nothing, or a pill written on the wrong
25
+ // row fails here; source-text assertions cannot see any of that.
26
+ //
27
+ // The vouch is DESCRIPTIVE and never authority (ADR 0016) — this file asserts
28
+ // only that the queue SAYS which class a row is, never that the class changes
29
+ // what the buttons do.
30
+ //
31
+ // Run: node tests/watch_applications_queue.mjs
32
+
33
+ import { strict as assert } from 'node:assert';
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import vm from 'node:vm';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { makeRunner, makeEl, parseInto } from './helpers.mjs';
39
+
40
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
42
+ const SRC = fs.readFileSync(path.join(HALL, 'watch.js'), 'utf8');
43
+ const PAGE = fs.readFileSync(path.join(HALL, 'watch.html'), 'utf8');
44
+
45
+ const { test, summary } = makeRunner();
46
+
47
+ const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
48
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
49
+
50
+ const tick = () => new Promise((r) => setTimeout(r, 0));
51
+ async function settle() { for (let i = 0; i < 40; i++) await tick(); }
52
+
53
+ // A request body (or an options array) the page built inside the vm carries that
54
+ // realm's prototypes, which deepStrictEqual reads as "same structure, different
55
+ // object". Round-tripping it through JSON compares the values, which is the only
56
+ // part under test.
57
+ const plain = (v) => JSON.parse(JSON.stringify(v));
58
+
59
+ const ok = (data) => ({ status: 200, ok: true, data });
60
+ const fail = (status, code, message) => ({ status, ok: false, data: { error: { code, message } } });
61
+
62
+ // Rows as GET /access-requests returns them: `vouched` is derived server-side
63
+ // from applicant_github_id IS NOT NULL (core_230), never stored twice.
64
+ const WAITING = [
65
+ {
66
+ id: '7', github_login: 'octocat', display_name: 'Mona Lisa', note: 'I want to build the map. (via platform My Projects)',
67
+ status: 'pending', created_at: '2026-09-01T10:00:00Z', resolved_at: null,
68
+ vouched: true, resolved_by_login: null,
69
+ },
70
+ {
71
+ id: '8', github_login: 'stranger', display_name: 'Mona Lisa', note: 'let me in',
72
+ status: 'pending', created_at: '2026-09-02T10:00:00Z', resolved_at: null,
73
+ vouched: false, resolved_by_login: null,
74
+ },
75
+ ];
76
+ const ADMITTED = [{
77
+ id: '3', github_login: 'earlybird', display_name: null, note: null,
78
+ status: 'invited', created_at: '2026-08-01T10:00:00Z', resolved_at: '2026-08-02T09:00:00Z',
79
+ vouched: true, resolved_by_login: 'theowner',
80
+ }];
81
+ const DECLINED = [{
82
+ id: '2', github_login: 'spammer', display_name: null, note: 'buy followers',
83
+ status: 'dismissed', created_at: '2026-07-01T10:00:00Z', resolved_at: '2026-07-01T11:00:00Z',
84
+ vouched: false, resolved_by_login: 'theowner',
85
+ }];
86
+
87
+ // ===========================================================================
88
+ // Boot the real page against a scripted API and hand back handles.
89
+ // ===========================================================================
90
+
91
+ async function boot(opts = {}) {
92
+ const {
93
+ rows = { pending: WAITING, invited: ADMITTED, dismissed: DECLINED },
94
+ rank = 'archon',
95
+ queueStatus = null, // non-200 for the queue read (403 seals it)
96
+ patchResult = null, // a scripted answer for PATCH /access-requests/:id
97
+ } = opts;
98
+
99
+ const requests = [];
100
+ const toasts = [];
101
+ const filterBars = [];
102
+
103
+ const api = {
104
+ async request(method, p, init) {
105
+ const body = init && init.body;
106
+ requests.push({ method, path: p, body });
107
+ const url = String(p).replace('/api/bongos', '');
108
+ const [route, query] = url.split('?');
109
+ if (method === 'GET' && route === '/me') return ok({ builder: { id: 1, rank, github_login: 'theowner' } });
110
+ if (method === 'GET' && route === '/access-requests') {
111
+ if (queueStatus) return { status: queueStatus, ok: false, data: null };
112
+ const status = new URLSearchParams(query || '').get('status') || 'pending';
113
+ return ok({ requests: rows[status] || [] });
114
+ }
115
+ if (method === 'PATCH' && route.startsWith('/access-requests/')) {
116
+ return patchResult || ok({ ok: true, request: { id: route.split('/').pop() } });
117
+ }
118
+ if (method === 'POST' && route === '/access-requests/invite') return ok({ ok: true });
119
+ // Every other panel on this page loads independently and degrades on its
120
+ // own; an unscripted shape is exactly the failure `section` swallows.
121
+ return ok({});
122
+ },
123
+ };
124
+
125
+ const documentObj = {
126
+ readyState: 'complete',
127
+ body: makeEl('body'),
128
+ documentElement: makeEl('html'),
129
+ createElement: (tag) => makeEl(tag),
130
+ getElementById(id) { return this.body.querySelector(`#${id}`); },
131
+ querySelector(sel) { return this.body.querySelector(sel); },
132
+ querySelectorAll(sel) { return this.body.querySelectorAll(sel); },
133
+ addEventListener() {},
134
+ };
135
+ // The REAL page markup, so #gate-body, #att-access and the section ids are
136
+ // the ones shipped rather than ones this test invented.
137
+ parseInto(documentObj.body, PAGE);
138
+
139
+ const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search: '', hash: '', assign() {} };
140
+
141
+ const kit = {
142
+ makeTabs: () => ({}),
143
+ makeLedger: () => ({ render() {} }),
144
+ makePager: () => ({ page: 1, perPage: 25 }),
145
+ emptyStateHtml: (msg) => `<div class="empty-state"><p>${escapeHtml(msg)}</p></div>`,
146
+ // Recorded rather than reimplemented: the contract watch.js depends on is
147
+ // "paint a control, call onChange with the chosen value", and a test that
148
+ // drives onChange drives exactly that.
149
+ makeFilterBar(o) {
150
+ filterBars.push(o);
151
+ const c = o.primary[0];
152
+ o.mount.innerHTML = `<div class="filters"><label class="filter">${escapeHtml(c.label)}<select id="${escapeHtml(c.id)}">`
153
+ + c.options.map((x) => `<option value="${escapeHtml(x.value)}"${String(x.value) === String(c.value) ? ' selected' : ''}>${escapeHtml(x.label)}</option>`).join('')
154
+ + '</select></label></div>';
155
+ return { el: o.mount, values: () => ({ [c.id]: c.value }) };
156
+ },
157
+ };
158
+
159
+ const windowObj = {
160
+ __BRANDING__: { project: { joinability: 'apply' } },
161
+ OTB: {
162
+ escapeHtml,
163
+ $: (sel) => documentObj.querySelector(sel),
164
+ num: (v) => Number(v),
165
+ fmtInt: (n) => String(n),
166
+ fmtCompact: (n) => String(n),
167
+ fmtDate: (iso) => String(iso).slice(0, 10),
168
+ rankLabel: (r) => String(r),
169
+ toast: (m) => toasts.push(m),
170
+ },
171
+ OTBKit: kit,
172
+ OTBBranding: { currencyLabel: () => 'Credits' },
173
+ BongosClient: { createClient: () => api },
174
+ location: locationObj,
175
+ addEventListener() {},
176
+ };
177
+
178
+ const sandbox = {
179
+ window: windowObj, document: documentObj, location: locationObj,
180
+ console: { log() {}, warn() {}, error() {} },
181
+ setTimeout, clearTimeout, Promise, Date, Math, JSON, Error, isNaN,
182
+ Number, String, Object, Array, Boolean, Set, Map, RegExp,
183
+ encodeURIComponent, decodeURIComponent, parseInt, parseFloat,
184
+ };
185
+ sandbox.globalThis = sandbox;
186
+ vm.createContext(sandbox);
187
+ vm.runInContext(SRC, sandbox, { filename: 'watch.js' });
188
+ await settle();
189
+
190
+ const gate = () => documentObj.getElementById('gate-body');
191
+ const rowsOut = () => gate().querySelectorAll('li.ov-row');
192
+ const rowFor = (id) => rowsOut().find((r) => r.getAttribute('data-req-id') === id);
193
+ const queueReads = () => requests.filter((r) => r.method === 'GET' && r.path.includes('/access-requests?'));
194
+ const patches = () => requests.filter((r) => r.method === 'PATCH');
195
+
196
+ async function click(rowId, action) {
197
+ const row = rowFor(rowId);
198
+ assert.ok(row, `no row rendered for request ${rowId}`);
199
+ const btn = row.querySelector(`[data-gate-action="${action}"]`);
200
+ assert.ok(btn, `row ${rowId} offers no "${action}" control — got: ${row.innerHTML}`);
201
+ btn.fire('click', {});
202
+ await settle();
203
+ return btn;
204
+ }
205
+
206
+ async function switchView(value) {
207
+ const bar = filterBars.at(-1);
208
+ assert.ok(bar, 'the queue offers no view switch');
209
+ await bar.onChange({ 'gate-status': value });
210
+ await settle();
211
+ }
212
+
213
+ return {
214
+ requests, toasts, filterBars, documentObj,
215
+ gate, rows: rowsOut, rowFor, queueReads, patches, click, switchView, settle,
216
+ attention: () => documentObj.getElementById('att-access').textContent,
217
+ lede: () => documentObj.getElementById('gate-scroll').querySelector('.scroll__lede').textContent,
218
+ };
219
+ }
220
+
221
+ // ===========================================================================
222
+ // The two-class row (ADR 0201) — the reason this queue is safe to read at all
223
+ // ===========================================================================
224
+
225
+ await test('every waiting request says whether the platform vouched for the name on it', async () => {
226
+ const h = await boot();
227
+ assert.equal(h.rows().length, 2, 'both waiting requests render');
228
+ for (const row of h.rows()) {
229
+ assert.ok(row.querySelector('.fact-pill'), `request ${row.getAttribute('data-req-id')} carries no vouch pill`);
230
+ }
231
+ });
232
+
233
+ await test('a vouched request reads as vouched and an unvouched one does not', async () => {
234
+ const h = await boot();
235
+ const vouched = h.rowFor('7').querySelector('.fact-pill').textContent;
236
+ const unvouched = h.rowFor('8').querySelector('.fact-pill').textContent;
237
+ assert.match(vouched, /vouched/i);
238
+ assert.doesNotMatch(vouched, /unvouched/i, 'the vouched row must not read as unvouched');
239
+ assert.match(unvouched, /unvouched/i);
240
+ });
241
+
242
+ await test('an unvouched request wearing a well-known display name is still marked unvouched', async () => {
243
+ // Both fixtures claim "Mona Lisa" — the impersonation shape the vouch closes.
244
+ const h = await boot();
245
+ const impostor = h.rowFor('8');
246
+ assert.match(impostor.textContent, /Mona Lisa/, 'the claimed name is still shown');
247
+ assert.match(impostor.querySelector('.fact-pill').textContent, /unvouched/i,
248
+ 'a name anyone could have typed must never render as confirmed');
249
+ });
250
+
251
+ await test('the vouch changes what the row SAYS, never what it offers (ADR 0016)', async () => {
252
+ const h = await boot();
253
+ const verbs = (id) => h.rowFor(id).querySelectorAll('[data-gate-action]').map((b) => b.getAttribute('data-gate-action'));
254
+ assert.deepEqual(verbs('7'), verbs('8'), 'a vouch is descriptive; it is not authority');
255
+ });
256
+
257
+ // ===========================================================================
258
+ // Deciding
259
+ // ===========================================================================
260
+
261
+ await test('a waiting request offers both decisions', async () => {
262
+ const h = await boot();
263
+ const verbs = h.rowFor('7').querySelectorAll('[data-gate-action]').map((b) => b.getAttribute('data-gate-action'));
264
+ assert.deepEqual(verbs, ['invited', 'dismissed']);
265
+ });
266
+
267
+ await test('Invite PATCHes the request to invited — the one write that admits anyone', async () => {
268
+ const h = await boot();
269
+ await h.click('7', 'invited');
270
+ const p = h.patches();
271
+ assert.equal(p.length, 1);
272
+ assert.equal(p[0].path, '/api/bongos/access-requests/7');
273
+ assert.deepEqual(plain(p[0].body), { status: 'invited' });
274
+ });
275
+
276
+ await test('Dismiss PATCHes the request to dismissed', async () => {
277
+ const h = await boot();
278
+ await h.click('8', 'dismissed');
279
+ assert.deepEqual(plain(h.patches()[0].body), { status: 'dismissed' });
280
+ });
281
+
282
+ await test('a decision re-reads the queue, so the row it settled leaves the list', async () => {
283
+ const h = await boot();
284
+ const before = h.queueReads().length;
285
+ await h.click('7', 'invited');
286
+ assert.ok(h.queueReads().length > before, 'the queue is re-read after a decision');
287
+ });
288
+
289
+ // ===========================================================================
290
+ // The history, and the rescind ADR 0208 said nothing offered
291
+ // ===========================================================================
292
+
293
+ await test('the queue offers all three lifecycle states as views', async () => {
294
+ const h = await boot();
295
+ const values = plain(h.filterBars.at(-1).primary[0].options).map((o) => o.value);
296
+ assert.deepEqual(values, ['pending', 'invited', 'dismissed']);
297
+ });
298
+
299
+ await test('switching to the admitted view re-reads the queue at that status', async () => {
300
+ const h = await boot();
301
+ await h.switchView('invited');
302
+ assert.match(h.queueReads().at(-1).path, /status=invited/);
303
+ });
304
+
305
+ await test('an admitted request offers Rescind, and it PATCHes back to dismissed', async () => {
306
+ const h = await boot();
307
+ await h.switchView('invited');
308
+ const btn = h.rowFor('3').querySelector('[data-gate-action]');
309
+ assert.match(btn.textContent, /Rescind/);
310
+ await h.click('3', 'dismissed');
311
+ assert.deepEqual(plain(h.patches().at(-1).body), { status: 'dismissed' });
312
+ assert.equal(h.patches().at(-1).path, '/api/bongos/access-requests/3');
313
+ });
314
+
315
+ await test('a declined request offers no decision — reviving one is the invite form, not a button', async () => {
316
+ const h = await boot();
317
+ await h.switchView('dismissed');
318
+ assert.equal(h.rowFor('2').querySelectorAll('[data-gate-action]').length, 0);
319
+ assert.ok(h.gate().querySelector('#gate-invite'), 'the invite form the server names is still on the page');
320
+ });
321
+
322
+ await test('a decided request names who decided it and when', async () => {
323
+ const h = await boot();
324
+ await h.switchView('invited');
325
+ const text = h.rowFor('3').textContent;
326
+ assert.match(text, /Admitted/);
327
+ assert.match(text, /@theowner/);
328
+ assert.match(text, /2026-08-02/);
329
+ });
330
+
331
+ await test('a refused rescind shows the server sentence, not [object Object]', async () => {
332
+ const h = await boot({
333
+ patchResult: fail(409, 'already_member',
334
+ 'They have already signed in, so this request is history — dismissing it would remove nothing.'),
335
+ });
336
+ await h.switchView('invited');
337
+ await h.click('3', 'dismissed');
338
+ const said = h.toasts.join(' | ');
339
+ assert.match(said, /already signed in/);
340
+ assert.doesNotMatch(said, /\[object Object\]/);
341
+ });
342
+
343
+ // ===========================================================================
344
+ // The board, the empty states, the seal
345
+ // ===========================================================================
346
+
347
+ await test('the attention tile counts waiting requests only, never the history on screen', async () => {
348
+ const h = await boot();
349
+ assert.equal(h.attention(), '2', 'the tile starts on the waiting count');
350
+ await h.switchView('dismissed');
351
+ assert.equal(h.attention(), '2',
352
+ 'reading the decided history must not repaint a tile whose own sub line says "pending"');
353
+ });
354
+
355
+ await test('each view has an empty state in its own words', async () => {
356
+ const h = await boot({ rows: { pending: [], invited: [], dismissed: [] } });
357
+ assert.match(h.gate().querySelector('.empty-state').textContent, /No pending access requests/);
358
+ await h.switchView('invited');
359
+ assert.match(h.gate().querySelector('.empty-state').textContent, /admitted/i);
360
+ await h.switchView('dismissed');
361
+ assert.match(h.gate().querySelector('.empty-state').textContent, /declined/i);
362
+ });
363
+
364
+ await test('an empty view still offers the invite form and the switch out of it', async () => {
365
+ const h = await boot({ rows: { pending: [], invited: [], dismissed: [] } });
366
+ assert.ok(h.gate().querySelector('#gate-invite'));
367
+ assert.ok(h.gate().querySelector('#gate-view').querySelector('select'));
368
+ });
369
+
370
+ await test('a 403 seals the section rather than blanking the page', async () => {
371
+ const h = await boot({ queueStatus: 403 });
372
+ assert.match(h.gate().textContent, /Archons only/);
373
+ assert.equal(h.rows().length, 0);
374
+ });
375
+
376
+ await test('the section lede tells the owner the queue is more than one decision', async () => {
377
+ const h = await boot();
378
+ assert.match(h.lede(), /decided/i);
379
+ });
380
+
381
+ summary();