@bongos/core 1.19.1044 → 1.19.1046

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 (89) hide show
  1. package/.bongos-core.json +106 -121
  2. package/docs/adr/0337-the-hub-account-is-the-person-a-hall-account-is-their-seat-in-one-project.md +14 -2
  3. package/docs/adr/README.md +1 -1
  4. package/docs/copy-inventory.md +142 -158
  5. package/docs/copy-registry.json +173 -360
  6. package/docs/file-map.md +2 -3
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/recipes/windows-builders.md +1 -1
  9. package/modules/hall-ui/public/atlas.html +1 -1
  10. package/modules/hall-ui/public/blockers.html +1 -1
  11. package/modules/hall-ui/public/board-room.html +1 -1
  12. package/modules/hall-ui/public/claim-action.js +250 -0
  13. package/modules/hall-ui/public/collab.css +1 -1
  14. package/modules/hall-ui/public/collab.html +1 -1
  15. package/modules/hall-ui/public/copy-desk.html +1 -1
  16. package/modules/hall-ui/public/deploy.html +1 -1
  17. package/modules/hall-ui/public/diagrams.html +1 -1
  18. package/modules/hall-ui/public/drachmae.html +1 -1
  19. package/modules/hall-ui/public/fleet.html +1 -1
  20. package/modules/hall-ui/public/gate.html +1 -1
  21. package/modules/hall-ui/public/goals-page.js +28 -44
  22. package/modules/hall-ui/public/goals-render.js +7 -28
  23. package/modules/hall-ui/public/goals.html +6 -3
  24. package/modules/hall-ui/public/government.html +1 -1
  25. package/modules/hall-ui/public/idea.html +1 -1
  26. package/modules/hall-ui/public/ideas.html +1 -1
  27. package/modules/hall-ui/public/index.html +1 -1
  28. package/modules/hall-ui/public/modules.html +1 -1
  29. package/modules/hall-ui/public/primer.html +1 -1
  30. package/modules/hall-ui/public/profile.html +1 -1
  31. package/modules/hall-ui/public/project-settings.html +1 -1
  32. package/modules/hall-ui/public/ranks.html +1 -1
  33. package/modules/hall-ui/public/roadmap.html +1 -1
  34. package/modules/hall-ui/public/{people.css → roster.css} +7 -11
  35. package/modules/hall-ui/public/roster.html +5 -5
  36. package/modules/hall-ui/public/roster.js +6 -7
  37. package/modules/hall-ui/public/roster.states.json +1 -1
  38. package/modules/hall-ui/public/sessions.html +1 -1
  39. package/modules/hall-ui/public/settings.html +2 -2
  40. package/modules/hall-ui/public/settings.js +2 -2
  41. package/modules/hall-ui/public/shell.js +3 -10
  42. package/modules/hall-ui/public/studio.html +1 -1
  43. package/modules/hall-ui/public/style.css +2 -9
  44. package/modules/hall-ui/public/task.html +1 -1
  45. package/modules/hall-ui/public/watch.html +1 -1
  46. package/modules/hall-ui/public/work.html +5 -2
  47. package/modules/hall-ui/public/work.js +35 -123
  48. package/modules/hall-ui/records/builders-roster.md +1 -1
  49. package/modules/hall-ui/records/roster-and-ledger.md +2 -2
  50. package/modules/platform-identity/account-visibility.js +3 -3
  51. package/modules/platform-identity/platform-identity.js +2 -2
  52. package/modules/platform-identity/routes/profile.js +1 -1
  53. package/modules/platform-identity/routes/sso.js +4 -3
  54. package/modules/platform-identity/scouting-authz.js +2 -2
  55. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  56. package/package-lock.json +2 -2
  57. package/package.json +1 -1
  58. package/release-notes.json +12 -0
  59. package/src/bongos/auth.js +9 -24
  60. package/src/bongos/serve-internal.js +13 -19
  61. package/src/module-api.js +1 -1
  62. package/tests/account_privacy_flags.mjs +1 -1
  63. package/tests/auth_resolve_failure.mjs +6 -6
  64. package/tests/claim_action.mjs +417 -0
  65. package/tests/core_upgrade_owner_door.mjs +1 -1
  66. package/tests/edit_hints.mjs +8 -8
  67. package/tests/effective_visibility_predicate.mjs +1 -1
  68. package/tests/goal_category_ui.mjs +3 -0
  69. package/tests/goal_criterion_confirm_ui.mjs +3 -0
  70. package/tests/goal_forest_page.mjs +3 -0
  71. package/tests/goal_invite_consent.mjs +3 -1
  72. package/tests/goal_mine_lens.mjs +3 -0
  73. package/tests/government_parity.mjs +1 -1
  74. package/tests/hall_builders_page_pass.mjs +2 -2
  75. package/tests/hall_mine_lens.mjs +3 -0
  76. package/tests/hall_nav.mjs +8 -8
  77. package/tests/hall_page_gate_map.mjs +14 -11
  78. package/tests/hall_roster_world.mjs +47 -78
  79. package/tests/module_contributions.mjs +0 -1
  80. package/tests/nav_permission_atoms.mjs +1 -1
  81. package/tests/{scouting_page_gate.mjs → page_gate_second_door.mjs} +34 -29
  82. package/tests/profile_rollup_consent.mjs +4 -4
  83. package/tests/profile_route.mjs +3 -26
  84. package/tests/scouting_owner_access_and_reach.mjs +4 -8
  85. package/modules/hall-ui/public/people.html +0 -79
  86. package/modules/hall-ui/public/people.js +0 -321
  87. package/modules/hall-ui/public/people.states.json +0 -89
  88. package/tests/people_directory_clip.mjs +0 -269
  89. package/tests/people_name_only.mjs +0 -263
@@ -0,0 +1,417 @@
1
+ // tests/claim_action.mjs
2
+ //
3
+ // Task 1003458 — the hall has ONE claim/release action, and it answers every
4
+ // refusal the server can give.
5
+ //
6
+ // Two halves:
7
+ // 1. the refusal copy is COMPLETE — a drift guard reads the server's own
8
+ // throw sites and fails if a code exists that this file has no sentence
9
+ // for. That is what makes "a new surface needs no new refusal handling"
10
+ // a fact rather than a hope;
11
+ // 2. the copies are GONE — work.js and goals-render.js no longer define their
12
+ // own, which is the deletion the refactor is proven by.
13
+ //
14
+ // Before this, both boards carried their own switch over 8 of the server's
15
+ // codes, drifted in wording (the same refusal said "The board will refresh."
16
+ // on /work and "Reloading." on /goals, and the goals copy had lost the
17
+ // "Wait for it to ship." advice), and neither had a single test.
18
+ //
19
+ // Run: node tests/claim_action.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { readFileSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, join } from 'node:path';
25
+ import { createRequire } from 'node:module';
26
+ import { makeRunner } from './helpers.mjs';
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const repoRoot = join(__dirname, '..');
30
+ const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
31
+ const require = createRequire(import.meta.url);
32
+ const { test, summary } = makeRunner();
33
+
34
+ const CA = require('../modules/hall-ui/public/claim-action.js');
35
+ const { friendlyClaimError, makeClaimAction, errCode, errDetails } = CA;
36
+
37
+ const envelope = (code, details = {}, message = '') => ({ error: { code, message, details } });
38
+ const GENERIC = /^The board could not complete that \(/;
39
+
40
+ // == 1. the refusal copy is complete ========================================
41
+
42
+ // The server's throw sites. Scraped, not hand-listed, so the guard cannot go
43
+ // stale the way the two hand-written switches did.
44
+ function serverRefusalCodes() {
45
+ const out = new Set();
46
+ for (const f of [
47
+ ['modules', 'lifecycle', 'db-claims.js'],
48
+ ['modules', 'lifecycle', 'goal-authz.js'],
49
+ ]) {
50
+ for (const m of read(...f).matchAll(/code:\s*'([A-Za-z_]+)'/g)) out.add(m[1]);
51
+ }
52
+ const routes = read('modules', 'lifecycle', 'routes', 'claims.js');
53
+ for (const m of routes.matchAll(/'(session_[a-z_]+|newcomer_must_ship_first)'/g)) out.add(m[1]);
54
+ return out;
55
+ }
56
+
57
+ // Batch-SHAPE errors, deliberately not this switch's job: a batch refusal is
58
+ // rendered per item by task-lib.js's friendlyBatchReason, and `batch_failed`
59
+ // has its own branch at both call sites (it must list the failing tasks, which
60
+ // a single sentence cannot). Excluded explicitly so the guard stays loud about
61
+ // everything else.
62
+ const BATCH_SHAPE = new Set(['BATCH_FAILED', 'BATCH_EMPTY', 'BATCH_DUPLICATE_IDS']);
63
+
64
+ await test('every server refusal code has a sentence — the drift guard', () => {
65
+ const codes = [...serverRefusalCodes()].filter((c) => !BATCH_SHAPE.has(c)).sort();
66
+ assert.ok(codes.length >= 14, `expected the scrape to find the refusal chain, got ${codes.length}`);
67
+ const unhandled = codes.filter((c) => GENERIC.test(friendlyClaimError(409, envelope(c))));
68
+ assert.deepEqual(unhandled, [],
69
+ `these server refusals fall through to the generic line — add a case in claim-action.js: ${unhandled.join(', ')}`);
70
+ });
71
+
72
+ await test('the five codes NEITHER board used to handle are covered', () => {
73
+ // The regression this task exists to close. Each of these rendered as
74
+ // "The board could not complete that (409). Try again." before the extraction.
75
+ for (const code of ['REBASE_REQUIRED', 'TASK_NOT_FOUND', 'OPEN_BLOCKER',
76
+ 'GOAL_MEMBERSHIP_REQUIRED', 'MODULE_CONFLICT']) {
77
+ const line = friendlyClaimError(409, envelope(code));
78
+ assert.doesNotMatch(line, GENERIC, `${code} still falls through`);
79
+ assert.ok(line.length > 20, `${code} needs a real sentence`);
80
+ }
81
+ });
82
+
83
+ await test('no refusal ever renders a raw object or undefined', () => {
84
+ for (const code of [...serverRefusalCodes()]) {
85
+ const line = friendlyClaimError(409, envelope(code));
86
+ assert.equal(typeof line, 'string');
87
+ assert.doesNotMatch(line, /\[object Object\]|undefined|null/);
88
+ }
89
+ });
90
+
91
+ await test('reads the ADR 0116 envelope AND the flat legacy shape', () => {
92
+ // task 1002462: `data.error` became an OBJECT and every string branch silently
93
+ // missed, so each refusal rendered as the generic line.
94
+ const viaEnvelope = friendlyClaimError(409, envelope('ALREADY_CLAIMED'));
95
+ const viaFlat = friendlyClaimError(409, { error: 'ALREADY_CLAIMED' });
96
+ assert.equal(viaEnvelope, viaFlat);
97
+ assert.doesNotMatch(viaEnvelope, GENERIC);
98
+ });
99
+
100
+ await test('the server\'s own hint wins where it names the task', () => {
101
+ // REBASE_REQUIRED and GOAL_MEMBERSHIP_REQUIRED carry a message that names the
102
+ // specific task or goal; a generic sentence would be strictly less useful.
103
+ const msg = 'Task #123 needs rebasing — run /strand-fix 123.';
104
+ assert.equal(friendlyClaimError(409, envelope('REBASE_REQUIRED', {}, msg)), msg);
105
+ // …and it still answers when the server sends none.
106
+ assert.doesNotMatch(friendlyClaimError(409, envelope('REBASE_REQUIRED')), GENERIC);
107
+ });
108
+
109
+ await test('TOUCHES_CONFLICT names the overlapping claims and keeps the advice', () => {
110
+ const line = friendlyClaimError(409, envelope('TOUCHES_CONFLICT', { conflicts: [{ title: 'Fix the pager' }] }));
111
+ assert.match(line, /Fix the pager/);
112
+ // The goals copy had dropped this half; it is the only part that tells the
113
+ // builder what to DO.
114
+ assert.match(line, /Wait for it to ship/);
115
+ });
116
+
117
+ await test('MODULE_CONFLICT names the module when the server sends one', () => {
118
+ assert.match(friendlyClaimError(409, envelope('MODULE_CONFLICT', { module_key: 'lifecycle' })), /"lifecycle"/);
119
+ assert.doesNotMatch(friendlyClaimError(409, envelope('MODULE_CONFLICT')), GENERIC);
120
+ });
121
+
122
+ await test('an unknown code still fails closed onto the generic line', () => {
123
+ assert.match(friendlyClaimError(409, envelope('SOMETHING_NEW_2027')), GENERIC);
124
+ });
125
+
126
+ // == 2. the action ==========================================================
127
+
128
+ function harness({ fail = null } = {}) {
129
+ const posts = [], toasts = [];
130
+ let refreshes = 0;
131
+ const action = makeClaimAction({
132
+ postJSON: async (path, body) => {
133
+ posts.push({ path, body });
134
+ if (fail) throw fail;
135
+ return { ok: true };
136
+ },
137
+ toast: (m) => toasts.push(m),
138
+ refresh: async () => { refreshes += 1; },
139
+ API: '/api/bongos',
140
+ });
141
+ return { action, posts, toasts, r: () => refreshes };
142
+ }
143
+
144
+ await test('claim posts the task id and does NOT bind the session', () => {
145
+ const h = harness();
146
+ return h.action.claim(42).then(() => {
147
+ assert.deepEqual(h.posts, [{ path: '/api/bongos/claims', body: { task_id: 42, bind_session: false } }]);
148
+ assert.match(h.toasts[0], /#42/);
149
+ assert.equal(h.r(), 1, 'a success refreshes the board');
150
+ });
151
+ });
152
+
153
+ await test('release resolves the claim as abandoned (there is no DELETE /claims/:id)', () => {
154
+ const h = harness();
155
+ return h.action.release('c9', 42).then(() => {
156
+ assert.equal(h.posts[0].path, '/api/bongos/claims/c9/resolve');
157
+ assert.deepEqual(h.posts[0].body, { outcome: 'abandoned' });
158
+ assert.match(h.toasts[0], /Released #42/);
159
+ });
160
+ });
161
+
162
+ await test('a refusal toasts the MAPPED sentence, not the raw error', async () => {
163
+ const h = harness({ fail: Object.assign(new Error('x'), { status: 409, data: envelope('OPEN_BLOCKER') }) });
164
+ const out = await h.action.claim(42);
165
+ assert.equal(out.ok, false);
166
+ assert.equal(h.toasts[0], friendlyClaimError(409, envelope('OPEN_BLOCKER')));
167
+ assert.doesNotMatch(h.toasts[0], GENERIC);
168
+ });
169
+
170
+ await test('a 409 refreshes the board — it is showing something stale', async () => {
171
+ const h = harness({ fail: Object.assign(new Error('x'), { status: 409, data: envelope('ALREADY_CLAIMED') }) });
172
+ await h.action.claim(42);
173
+ assert.equal(h.r(), 1);
174
+ });
175
+
176
+ await test('a non-409 failure does NOT refresh', async () => {
177
+ const h = harness({ fail: Object.assign(new Error('x'), { status: 500, data: {} }) });
178
+ await h.action.claim(42);
179
+ assert.equal(h.r(), 0, 'a 500 says nothing about the board being stale');
180
+ });
181
+
182
+ await test('the auth-redirect sentinel is swallowed, never toasted', async () => {
183
+ const h = harness({ fail: new Error('redirecting to auth') });
184
+ const out = await h.action.claim(42);
185
+ assert.equal(out.redirected, true);
186
+ assert.deepEqual(h.toasts, [], 'a toast during a redirect is noise the builder never reads');
187
+ });
188
+
189
+ await test('a refresh that throws after a refusal does not mask the refusal', async () => {
190
+ const posts = [], toasts = [];
191
+ const action = makeClaimAction({
192
+ postJSON: async () => { throw Object.assign(new Error('x'), { status: 409, data: envelope('ALREADY_CLAIMED') }); },
193
+ toast: (m) => toasts.push(m),
194
+ refresh: async () => { throw new Error('network died'); },
195
+ });
196
+ const out = await action.claim(42); // must not reject
197
+ assert.equal(out.ok, false);
198
+ assert.equal(toasts.length, 1, 'the refusal was still told');
199
+ });
200
+
201
+ await test('the dry run reports what a bulk claim WOULD do', async () => {
202
+ const action = makeClaimAction({
203
+ postJSON: async (path, body) => {
204
+ assert.equal(path, '/api/bongos/claims/batch/validate');
205
+ assert.deepEqual(body, { task_ids: [7, 8] });
206
+ return { parallel_safe: true, results: [{ task_id: 7, ok: true }] };
207
+ },
208
+ toast: () => {}, refresh: async () => {},
209
+ });
210
+ const out = await action.validateMany([7, 8]);
211
+ assert.equal(out.parallelSafe, true);
212
+ assert.equal(out.byId.get('7').ok, true);
213
+ });
214
+
215
+ await test('a dry run that FAILS answers "not safe, know nothing" — never throws, never toasts', async () => {
216
+ // The caller uses this to decide whether to offer a parallel prompt. Copying a
217
+ // prompt for tasks nobody could verify is worse than offering nothing, and an
218
+ // unavailable pre-check is not a refusal to report at the builder.
219
+ const toasts = [];
220
+ const action = makeClaimAction({
221
+ postJSON: async () => { throw Object.assign(new Error('down'), { status: 500 }); },
222
+ toast: (m) => toasts.push(m), refresh: async () => {},
223
+ });
224
+ const out = await action.validateMany([7]);
225
+ assert.equal(out.ok, false);
226
+ assert.equal(out.parallelSafe, false);
227
+ assert.equal(out.byId.size, 0);
228
+ assert.deepEqual(toasts, [], 'a best-effort pre-check must stay silent');
229
+ });
230
+
231
+ // == 3. the deletion ========================================================
232
+
233
+ // The assertion that was MISSING in the first round, and the gap it left is the
234
+ // whole reason this section exists: the shared action can be defined, unit-tested
235
+ // and documented as the contract while every real call site still hand-rolls its
236
+ // own POST + toast + refresh. Green tests, dead code, and a CLAUDE.md rule
237
+ // describing behaviour the code does not have. Assert the call sites, not the
238
+ // module.
239
+
240
+ await test('NOBODY posts to /claims except through the shared action', () => {
241
+ // The falsifiable form of "proven by deletion". If a surface reintroduces its
242
+ // own claim POST, this fails — which is what "a new claim surface needs no new
243
+ // refusal handling" has to mean in practice.
244
+ const offenders = [];
245
+ for (const f of ['work.js', 'goals-page.js', 'goals-render.js', 'task.js']) {
246
+ const src = read('modules', 'hall-ui', 'public', f);
247
+ if (/postJSON\(\s*`\$\{API\}\/claims/.test(src)) offenders.push(f);
248
+ }
249
+ assert.deepEqual(offenders, [],
250
+ `these still hand-roll a claim/release POST instead of calling claimAction: ${offenders.join(', ')}`);
251
+ });
252
+
253
+ await test('both boards CONSTRUCT the shared action, not just its error text', () => {
254
+ for (const f of ['work.js', 'goals-page.js']) {
255
+ const src = read('modules', 'hall-ui', 'public', f);
256
+ assert.match(src, /makeClaimAction\(\{/, `${f} must build the action`);
257
+ assert.match(src, /describeBatchReason: friendlyBatchReason/,
258
+ `${f} must inject the batch reason mapper, or a batch refusal loses its per-task detail`);
259
+ }
260
+ });
261
+
262
+ await test('every claim/release entry point routes through it', () => {
263
+ const sites = {
264
+ 'work.js': ['doClaim', 'doRelease', 'doBulkClaim', 'doBulkRelease'],
265
+ 'goals-page.js': ['doGoalClaim', 'doGoalRelease', 'doGoalBulkClaim'],
266
+ };
267
+ for (const [f, fns] of Object.entries(sites)) {
268
+ const src = read('modules', 'hall-ui', 'public', f);
269
+ for (const fn of fns) {
270
+ const m = src.match(new RegExp(`async function ${fn}\\([^)]*\\) \\{[\\s\\S]*?\\n \\}`));
271
+ assert.ok(m, `${f}: ${fn} not found`);
272
+ assert.match(m[0], /claimAction\.(claim|release|claimMany|releaseMany)\(/,
273
+ `${f}: ${fn} does not go through the shared action`);
274
+ assert.doesNotMatch(m[0], /friendlyClaimError\(/,
275
+ `${f}: ${fn} still maps its own refusal`);
276
+ }
277
+ }
278
+ });
279
+
280
+ await test('no call site reloads on top of the action\'s own refresh', async () => {
281
+ // The regression the extraction introduced on /goals: the action's injected
282
+ // refresh reloads the open goal's task list, and doGoalBulkClaim's finally
283
+ // reloaded it AGAIN — so every bulk claim there fetched and re-rendered
284
+ // twice. work.js's doBulkClaim is the correct shape: redraw the toolbar,
285
+ // never re-fetch. A finally that awaits a loader is the tell.
286
+ const src = read('modules', 'hall-ui', 'public', 'goals-page.js');
287
+ for (const fn of ['doGoalClaim', 'doGoalRelease', 'doGoalBulkClaim']) {
288
+ const m = src.match(new RegExp(`async function ${fn}\\([^)]*\\) \\{[\\s\\S]*?\\n \\}`));
289
+ assert.ok(m, `${fn} not found`);
290
+ assert.doesNotMatch(m[0], /finally \{[\s\S]*?await loadGoalTasks\(/,
291
+ `${fn}: the shared action already refreshed — this reloads a second time`);
292
+ }
293
+ });
294
+
295
+ await test('the action refreshes ONCE per call, not per outcome', async () => {
296
+ let refreshes = 0;
297
+ const mk = (postJSON) => makeClaimAction({
298
+ postJSON, toast: () => {}, refresh: async () => { refreshes += 1; },
299
+ describeBatchReason: (f) => f.reason,
300
+ });
301
+ await mk(async () => ({}))?.claimMany([7, 8]);
302
+ assert.equal(refreshes, 1, 'success');
303
+ refreshes = 0;
304
+ await mk(async () => { throw Object.assign(new Error('x'), { status: 409, data: envelope('batch_failed', { failures: [] }) }); }).claimMany([7, 8]);
305
+ assert.equal(refreshes, 1, 'batch refusal');
306
+ refreshes = 0;
307
+ await mk(async () => { throw Object.assign(new Error('x'), { status: 500, data: {} }); }).claimMany([7, 8]);
308
+ assert.equal(refreshes, 0, 'a 500 says nothing about the board being stale');
309
+ });
310
+
311
+ await test('the batch refusal still names the failing tasks', async () => {
312
+ // All-or-nothing: a batch_failed 409 means NOTHING was claimed, so a generic
313
+ // sentence leaves the builder with no idea what to deselect.
314
+ const toasts = [];
315
+ const action = makeClaimAction({
316
+ postJSON: async () => { throw Object.assign(new Error('x'), { status: 409, data: envelope('batch_failed', { failures: [{ task_id: 7, reason: 'ALREADY_CLAIMED' }] }) }); },
317
+ toast: (m) => toasts.push(m),
318
+ refresh: async () => {},
319
+ describeBatchReason: (f) => (f.reason === 'ALREADY_CLAIMED' ? 'already claimed' : f.reason),
320
+ });
321
+ await action.claimMany([7, 8]);
322
+ assert.match(toasts[0], /#7 \(already claimed\)/);
323
+ assert.doesNotMatch(toasts[0], GENERIC);
324
+ });
325
+
326
+ await test('a selection of ONE takes the single endpoint, not a batch of one', async () => {
327
+ const posts = [];
328
+ const action = makeClaimAction({
329
+ postJSON: async (path, body) => { posts.push(path); return {}; },
330
+ toast: () => {}, refresh: async () => {},
331
+ });
332
+ await action.claimMany([7]);
333
+ assert.equal(posts[0], '/api/bongos/claims', 'the batch route reports per-item reasons — wrong shape for one');
334
+ posts.length = 0;
335
+ await action.releaseMany([['c1', 7]]);
336
+ assert.equal(posts[0], '/api/bongos/claims/c1/resolve');
337
+ });
338
+
339
+ await test('a batch release counts rather than lists — it is NOT all-or-nothing', async () => {
340
+ const toasts = [];
341
+ const action = makeClaimAction({
342
+ postJSON: async () => ({ released: [1, 2], failures: [3] }),
343
+ toast: (m) => toasts.push(m), refresh: async () => {},
344
+ });
345
+ await action.releaseMany([['c1', 1], ['c2', 2], ['c3', 3]]);
346
+ assert.match(toasts[0], /Released 2 of 3 — 1 could not be released/);
347
+ });
348
+
349
+ await test('the refusal text is PLAIN TEXT, for textContent — never markup', () => {
350
+ // It interpolates server-controlled strings (module_key, conflict titles, the
351
+ // REBASE_REQUIRED/GOAL_MEMBERSHIP_REQUIRED messages). Every consumer passes it
352
+ // to toast(), which sets textContent; a future caller using innerHTML would
353
+ // turn that into an injection. Pinned here so the contract is checkable.
354
+ const line = friendlyClaimError(409, envelope('MODULE_CONFLICT', { module_key: '<img src=x onerror=alert(1)>' }));
355
+ assert.match(line, /<img src=x onerror=alert\(1\)>/, 'it does not escape — it is text, not markup');
356
+ for (const f of ['work.js', 'goals-page.js']) {
357
+ const src = read('modules', 'hall-ui', 'public', f);
358
+ assert.doesNotMatch(src, /innerHTML\s*=\s*[^;]*friendlyClaimError/, `${f} must not render it as markup`);
359
+ }
360
+ });
361
+
362
+ await test('work.js no longer defines its own refusal switch', () => {
363
+ const src = read('modules', 'hall-ui', 'public', 'work.js');
364
+ assert.doesNotMatch(src, /function friendlyActionError/);
365
+ assert.match(src, /window\.OTBClaimAction/, 'it must consume the shared one');
366
+ });
367
+
368
+ await test('goals-render.js no longer defines its own', () => {
369
+ const src = read('modules', 'hall-ui', 'public', 'goals-render.js');
370
+ assert.doesNotMatch(src, /function friendlyClaimError/);
371
+ assert.doesNotMatch(src, /function errCode/, 'its envelope readers were a third copy');
372
+ assert.match(src, /window\.OTBClaimAction/);
373
+ });
374
+
375
+ await test('both boards load claim-action.js BEFORE the script that destructures it', () => {
376
+ for (const [page, consumer] of [['work.html', 'work.js'], ['goals.html', 'goals-render.js']]) {
377
+ const html = read('modules', 'hall-ui', 'public', page);
378
+ const shared = html.indexOf('claim-action.js');
379
+ const after = html.indexOf(consumer + '?v=');
380
+ assert.ok(shared > -1, `${page} must load claim-action.js`);
381
+ assert.ok(after > shared, `${page}: ${consumer} must load AFTER claim-action.js`);
382
+ }
383
+ });
384
+
385
+ await test('the cache-bust moved on every page whose script changed', () => {
386
+ // A fix that reaches nobody looks identical to a fix that does not work.
387
+ for (const [page, scripts] of [
388
+ ['work.html', ['work.js']],
389
+ ['goals.html', ['goals-render.js', 'goals-page.js']],
390
+ ]) {
391
+ const html = read('modules', 'hall-ui', 'public', page);
392
+ for (const s of scripts) {
393
+ assert.match(html, new RegExp(s.replace('.', '\\.') + '\\?v=2026-09-26-claimaction1'), `${page}: ${s} not busted`);
394
+ }
395
+ }
396
+ });
397
+
398
+ await test('task.js keeps its own LIFECYCLE mapper — a disjoint code set', () => {
399
+ // Deliberately NOT merged: it maps POST /tasks/:id/ship + /confirm refusals
400
+ // (task_not_confirmed, verify_attestation_required), which share no code with
401
+ // the claim gate. One switch over both would be wrong about half its cases.
402
+ const src = read('modules', 'hall-ui', 'public', 'task.js');
403
+ assert.match(src, /function friendlyActionError/);
404
+ assert.match(src, /task_not_confirmed/);
405
+ assert.doesNotMatch(src, /case 'ALREADY_CLAIMED'/);
406
+ });
407
+
408
+ await test('the module is dependency-free and node-require-safe', () => {
409
+ const src = read('modules', 'hall-ui', 'public', 'claim-action.js');
410
+ // It may PUBLISH window.OTBClaimAction; it must not READ any other toolkit
411
+ // global, or load order stops being "before the page script".
412
+ const others = [...src.matchAll(/window\.OTB([A-Za-z]+)/g)].map((m) => m[1]).filter((n) => n !== 'ClaimAction');
413
+ assert.deepEqual(others, [], `reaches for other globals: ${others.join(', ')}`);
414
+ assert.ok(typeof errCode === 'function' && typeof errDetails === 'function');
415
+ });
416
+
417
+ summary();
@@ -28,7 +28,7 @@ const require = createRequire(import.meta.url);
28
28
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
29
 
30
30
  // The session double must be installed BEFORE auth.js loads: auth.js destructures
31
- // lookupSession at require time (the tests/scouting_page_gate.mjs precedent).
31
+ // lookupSession at require time (the tests/page_gate_second_door.mjs precedent).
32
32
  const dbKernel = require('../src/bongos/db-kernel.js');
33
33
  let nextSession = null;
34
34
  dbKernel.lookupSession = async () => nextSession;
@@ -21,7 +21,7 @@ const hints = require(HOOK);
21
21
 
22
22
  test('each generator input maps to its generator; unrelated paths map to nothing', () => {
23
23
  const cases = [
24
- ['modules/hall-ui/public/people.js', 'copy-inventory.js'],
24
+ ['modules/hall-ui/public/roster.js', 'copy-inventory.js'],
25
25
  ['modules/status-ui/public/index.html', 'copy-inventory.js'],
26
26
  ['src/bongos/auth.js', 'gen-repo-map.js'],
27
27
  ['modules/lifecycle/routes/claims.js', 'gen-api-docs.js'],
@@ -39,12 +39,12 @@ test('each generator input maps to its generator; unrelated paths map to nothing
39
39
  });
40
40
 
41
41
  test('rel() reconciles C:\\, C:/ and /c/ shapes between CLAUDE_PROJECT_DIR and file_path', () => {
42
- const want = 'modules/hall-ui/public/people.js';
43
- assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\people.js', 'C:\\r\\repo'), want);
44
- assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\people.js', 'C:/r/repo'), want);
45
- assert.equal(hints.rel('/c/r/repo/modules/hall-ui/public/people.js', 'C:\\r\\repo'), want);
46
- assert.equal(hints.rel('c:/r/repo/modules/hall-ui/public/people.js', 'C:/r/repo/'), want);
47
- assert.equal(hints.rel('/home/u/repo/modules/hall-ui/public/people.js', '/home/u/repo'), want);
42
+ const want = 'modules/hall-ui/public/roster.js';
43
+ assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\roster.js', 'C:\\r\\repo'), want);
44
+ assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\roster.js', 'C:/r/repo'), want);
45
+ assert.equal(hints.rel('/c/r/repo/modules/hall-ui/public/roster.js', 'C:\\r\\repo'), want);
46
+ assert.equal(hints.rel('c:/r/repo/modules/hall-ui/public/roster.js', 'C:/r/repo/'), want);
47
+ assert.equal(hints.rel('/home/u/repo/modules/hall-ui/public/roster.js', '/home/u/repo'), want);
48
48
  });
49
49
 
50
50
  test('run as a hook: prints one hint for a generator input, nothing for an unrelated file, exit 0 both times', () => {
@@ -53,7 +53,7 @@ test('run as a hook: prints one hint for a generator input, nothing for an unrel
53
53
  input: JSON.stringify({ tool_input: { file_path: path.join(projectDir, ...relPath.split('/')) } }),
54
54
  encoding: 'utf8', timeout: 15000, env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir },
55
55
  });
56
- const a = run('modules/hall-ui/public/people.js');
56
+ const a = run('modules/hall-ui/public/roster.js');
57
57
  assert.equal(a.status, 0, a.stderr);
58
58
  assert.match(a.stdout, /copy-registry\.json is now stale/);
59
59
  assert.match(a.stdout, /copy-inventory\.js/);
@@ -1122,7 +1122,7 @@ await ta('private accounts are absent from the leaderboard/directory rollup', as
1122
1122
  const rows = await pi.listScoutingRollup({ limit: 50, pool });
1123
1123
  const shown = rows.map((r) => r.github_login).sort();
1124
1124
  assert.deepEqual(shown, ['octo', 'shifted'],
1125
- 'only active, publicly-visible, unhidden accounts reach /people and /scouting — '
1125
+ 'only active, publicly-visible, unhidden accounts reach /scouting — '
1126
1126
  + `got ${JSON.stringify(shown)}; ghost/scout are private, shy is hidden, newbie is provisional, moved is private, legacy never accepted the terms (ADR 0213)`);
1127
1127
  });
1128
1128
 
@@ -123,6 +123,9 @@ async function renderList() {
123
123
  windowObj.OTB.$ = $;
124
124
  windowObj.OTB.fmtDate = () => '';
125
125
  windowObj.OTB.toast = () => {};
126
+ // task 1003458: the page loads claim-action.js before this script; the stub
127
+ // must mirror that, or the destructure of window.OTBClaimAction throws.
128
+ vm.runInContext(read('modules/hall-ui/public/claim-action.js'), sandbox, { filename: 'claim-action.js' });
126
129
  vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
127
130
  vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
128
131
  vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
@@ -153,6 +153,9 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
153
153
  const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object, JSON, Math, encodeURIComponent, Promise };
154
154
  sandbox.globalThis = sandbox;
155
155
  vm.createContext(sandbox);
156
+ // task 1003458: the page loads claim-action.js before this script; the stub
157
+ // must mirror that, or the destructure of window.OTBClaimAction throws.
158
+ vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/claim-action.js'), 'utf8'), sandbox, { filename: 'claim-action.js' });
156
159
  vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
157
160
  vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
158
161
  vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
@@ -77,6 +77,9 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
77
77
  vm.runInContext(LIB_JS, sandbox, { filename: 'goal-forest-lib.js' });
78
78
  vm.runInContext(FIT_JS, sandbox, { filename: 'text-fit.js' });
79
79
  vm.runInContext(FOREST_RENDER_JS, sandbox, { filename: 'goal-forest-render.js' });
80
+ // task 1003458: the page loads claim-action.js before this script; the stub
81
+ // must mirror that, or the destructure of window.OTBClaimAction throws.
82
+ vm.runInContext(read('claim-action.js'), sandbox, { filename: 'claim-action.js' });
80
83
  vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
81
84
 
82
85
  const state = { current: 7, forest: null, forestError: false };
@@ -169,7 +169,9 @@ function harness({ pendingRequests = [] } = {}) {
169
169
  };
170
170
  sandbox.globalThis = sandbox;
171
171
  vm.createContext(sandbox);
172
- for (const f of ['hall-kit.js', 'task-lib.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js']) {
172
+ // task 1003458: claim-action.js sits between task-lib and goals-render on the
173
+ // real page (goals.html); the stub mirrors that order.
174
+ for (const f of ['hall-kit.js', 'task-lib.js', 'claim-action.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js']) {
173
175
  vm.runInContext(read(P + f), sandbox, { filename: f });
174
176
  }
175
177
 
@@ -147,6 +147,9 @@ async function boot({
147
147
  vm.runInContext(GOAL_FOREST_LIB_SRC, sandbox, { filename: 'goal-forest-lib.js' });
148
148
  windowObj.OTBGoalForestLib = sandbox.OTBGoalForestLib;
149
149
  vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
150
+ // task 1003458: the page loads claim-action.js before this script; the stub
151
+ // must mirror that, or the destructure of window.OTBClaimAction throws.
152
+ vm.runInContext(hall('claim-action.js'), sandbox, { filename: 'claim-action.js' });
150
153
  vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
151
154
  vm.runInContext(GOALS_PAGE_SRC, sandbox, { filename: 'goals-page.js' });
152
155
  for (let i = 0; i < 30; i += 1) await new Promise((r) => setTimeout(r, 0));
@@ -280,7 +280,7 @@ function parsePageGates(src) {
280
280
  // Each entry's own object literal is bounded by [^}]* so the scan cannot run past a
281
281
  // gate that lacks a `permission` key and silently adopt the NEXT gate's. `permission`
282
282
  // is then found ANYWHERE inside that literal rather than assumed to come first —
283
- // requireProjectCuratePage already carries a second key, and key order in an object
283
+ // every gate carries a `deniedTo` beside it, and key order in an object
284
284
  // literal is not a contract anyone maintains.
285
285
  for (const m of block[1].matchAll(/(require[A-Za-z]*Page):\s*Object\.freeze\(\{([^}]*)\}\)/g)) {
286
286
  const perm = m[2].match(/permission:\s*'([^']+)'/);
@@ -223,9 +223,9 @@ test('B: a payload with no id degrades to the place alone', async () => {
223
223
  });
224
224
 
225
225
  test('B: the id wears the same mono/faint voice as the login', () => {
226
- const css = read('people.css');
226
+ const css = read('roster.css');
227
227
  const i = css.indexOf('.ledger__place .ledger__id {');
228
- assert.ok(i > 0, 'people.css styles the roster id');
228
+ assert.ok(i > 0, 'roster.css styles the roster id');
229
229
  const block = css.slice(i, css.indexOf('}', i));
230
230
  for (const token of ['var(--font-mono)', 'var(--ink-faint)']) {
231
231
  assert.ok(block.includes(token), `the id spends ${token}, the voice .dir-login already uses`);
@@ -201,6 +201,9 @@ function bootWorkBoard({ signedIn = true, goalsFail = false } = {}) {
201
201
  vm.runInContext(WORK_SUMMARY_JS, sandbox, { filename: 'work-summary.js' });
202
202
  windowObj.OTBMineLens = sandbox.OTBMineLens;
203
203
  windowObj.OTBWorkSummary = sandbox.OTBWorkSummary;
204
+ // task 1003458: the page loads claim-action.js before this script; the stub
205
+ // must mirror that, or the destructure of window.OTBClaimAction throws.
206
+ vm.runInContext(fs.readFileSync(path.join(HALL, 'claim-action.js'), 'utf8'), sandbox, { filename: 'claim-action.js' });
204
207
  vm.runInContext(WORK_JS, sandbox, { filename: 'work.js' });
205
208
  (documentObj.listeners.DOMContentLoaded || []).forEach((fn) => fn());
206
209
 
@@ -266,10 +266,9 @@ test('sidebar groups read by audience, in order', () => {
266
266
  assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'ideas', 'thinking', 'copy-desk', 'studio'],
267
267
  'the unlabeled group is the project itself (task 1002425 order)');
268
268
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
269
- // People (task 1002577): the /scouting directory graduated into the hall.
270
- // Gated cosmetically to match its data endpoint — `project.curate`, floor METIC,
271
- // so the nav entry reads 'government' since ADR 0174 (it said 'archon', then
272
- // was stricter than the wall). requireProjectCuratePage is the wall server-side.
269
+ // task 1003444: 'people' LEFT the group — the owner deleted the hall's
270
+ // cross-project directory (ADR 0337 D5); its home is the hub, and /people
271
+ // redirects to /roster. Builders is the hall's directory now.
273
272
  // task 1003172 (C02 of goal 1000075): 'collab' joins Community, FIRST. The
274
273
  // group is picked by whose data it is, and a help request is another builder
275
274
  // reaching you — not your own numbers (that is You), and not reference (Docs).
@@ -280,7 +279,8 @@ test('sidebar groups read by audience, in order', () => {
280
279
  // changed: it was a hash view on index.html (href /#leaderboard, view:
281
280
  // 'leaderboard') and is now a real page at /roster labelled Builders. That is
282
281
  // what made this group read as one real page plus a link into the home page.
283
- assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster', 'people'], 'Community = everyone else');
282
+ assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster'], 'Community = everyone else, in this project');
283
+ assert.ok(!nav.flatMap((g) => g.items).some((i) => /^\/people\b/.test(i.href)), 'no nav item points at the deleted /people');
284
284
  assert.deepEqual(nav[3].items.map((i) => i.id),
285
285
  ['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
286
286
  // 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
@@ -343,11 +343,11 @@ test('THE CORRECTION: Community shows a xenos two items, Collab and Builders', (
343
343
  test('neither Community page hides behind a rank the page itself does not enforce', () => {
344
344
  // The invariant this task exists to restore: a nav flag may be narrower than
345
345
  // the wall for cosmetic reasons (/profile does it), but it must never assert a
346
- // RANK the page gate does not. /people keeps its rank flag legitimately — it
347
- // fronts project.curate, which requireProjectCuratePage really does enforce.
346
+ // RANK the page gate does not. /people was the one item with a legitimate rank
347
+ // flag; it left the hall in task 1003444, so the group now carries none.
348
348
  const community = parseNav(boot().sidebarHtml).find((g) => g.label === 'Community');
349
349
  const ranked = community.items.filter((i) => RANK_GATES.has(i.gate)).map((i) => i.id);
350
- assert.deepEqual(ranked, ['people'], 'only /people carries a rank flag, and its API enforces one');
350
+ assert.deepEqual(ranked, [], 'no Community item carries a rank flag');
351
351
  });
352
352
 
353
353
  test('no group head is gated wider than its own items', () => {