@bongos/core 1.19.346 → 1.19.348

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.
@@ -32,6 +32,23 @@ const DEFAULT_CHANNEL = 'patch';
32
32
  // core template — a fresh instance subscribes nothing until the operator lists a target here.
33
33
  const SUBSCRIPTIONS_REL = path.join('config', 'update-subscriptions.json');
34
34
 
35
+ // Why a roster came back the way it did. The routine reports this EVERY run: an upgrader whose only
36
+ // job is keeping instances current must never be indistinguishable from one that has stopped. Before
37
+ // task 1003158 an absent manifest, a malformed manifest and a deliberately empty roster all produced
38
+ // the same silent `instances: []`, which is exactly how cloudbongos.com sat 36 releases behind for
39
+ // days with nothing complaining (the same failure SHAPE as blocker 1000097 / task 1002521).
40
+ const ROSTER_OK = 'ok'; // at least one usable entry
41
+ const ROSTER_ABSENT = 'manifest-absent'; // no file at all
42
+ const ROSTER_MALFORMED = 'manifest-malformed';// file present, JSON unparseable
43
+ const ROSTER_EMPTY = 'roster-empty'; // file parsed, nothing subscribable in it
44
+ const ROSTER_REASONS = [ROSTER_OK, ROSTER_ABSENT, ROSTER_MALFORMED, ROSTER_EMPTY];
45
+
46
+ // How many releases a subscribed instance may fall behind before "the upgrader stopped" is the only
47
+ // remaining explanation. A successful sweep takes the HIGHEST permitted version in one jump, so a
48
+ // healthy roster returns to 0 behind after every run — a backlog that keeps growing means no run is
49
+ // landing. 5 leaves room for a busy publish-on-merge day between two daily sweeps without crying wolf.
50
+ const DEFAULT_STALENESS_THRESHOLD = 5;
51
+
35
52
  // ---- semver (stable x.y.z, optional prerelease tag) -------------------------
36
53
  // A deliberately small parser — the repo avoids an npm-semver dependency in the CLI path (see
37
54
  // upgrade.js versionFromDep). We only need compare + the major/minor/patch fields.
@@ -143,16 +160,23 @@ function parseNpmViewVersions(stdout) {
143
160
  // this control plane keeps up to date. Never throws: a missing/malformed file ⇒ an empty roster
144
161
  // (subscribe nothing), because an unreadable manifest must not upgrade anything. Each entry is
145
162
  // normalized to { slug, dir, service, healthUrl, channel } with the channel validated + defaulted.
163
+ //
164
+ // Still fails CLOSED, no longer fails QUIET (task 1003158): every return carries a `reason` from
165
+ // ROSTER_REASONS and a `dropped` count of entries that were unusable, so a caller can say WHY it is
166
+ // about to do nothing. Pair it with describeRoster() for the line to print.
146
167
  function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SUBSCRIPTIONS_REL } = {}) {
147
168
  const abs = path.isAbsolute(file) ? file : path.join(instanceDir, file);
148
169
  let raw;
149
- try { raw = fsImpl.readFileSync(abs, 'utf8'); } catch (_) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: false }; }
170
+ try { raw = fsImpl.readFileSync(abs, 'utf8'); } catch (_) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: false, dropped: 0, reason: ROSTER_ABSENT }; }
150
171
  let json;
151
- try { json = JSON.parse(raw); } catch (e) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: true, error: `malformed JSON: ${e.message}` }; }
172
+ try { json = JSON.parse(raw); } catch (e) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: true, dropped: 0, error: `malformed JSON: ${e.message}`, reason: ROSTER_MALFORMED }; }
152
173
  const defaultChannel = normalizeChannel(json.defaultChannel);
153
174
  const rows = Array.isArray(json.instances) ? json.instances : [];
154
- const instances = rows
155
- .filter((r) => r && typeof r === 'object' && r.dir) // an entry without a dir can't be upgraded — drop it
175
+ // An entry without a dir can't be upgraded — drop it, but COUNT the drops. Silently discarding an
176
+ // operator's malformed row is how an instance believes it is enrolled while nothing ever touches it.
177
+ const usable = rows.filter((r) => r && typeof r === 'object' && r.dir);
178
+ const dropped = rows.length - usable.length;
179
+ const instances = usable
156
180
  .map((r) => ({
157
181
  slug: String(r.slug || r.dir),
158
182
  dir: String(r.dir),
@@ -178,7 +202,90 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
178
202
  ? Object.fromEntries(Object.entries(r.env).filter(([, v]) => typeof v === 'string'))
179
203
  : {},
180
204
  }));
181
- return { defaultChannel, instances, source: abs, present: true };
205
+ return {
206
+ defaultChannel, instances, source: abs, present: true, dropped,
207
+ reason: instances.length ? ROSTER_OK : ROSTER_EMPTY,
208
+ };
209
+ }
210
+
211
+ // ---- reporting: make "nothing happened" audible -----------------------------
212
+
213
+ // Turn a loadSubscriptions() result into the line a routine prints EVERY run, plus a level a caller
214
+ // can act on ('ok' | 'warn' | 'alarm'). The judgement that matters is `controlPlane`: a fresh
215
+ // instance subscribing nothing is the shipped default (ADR 0136 — the manifest ships empty), but a
216
+ // CONTROL PLANE that subscribes nothing has silently stopped doing its only job, and that is an alarm.
217
+ // A malformed manifest is an alarm everywhere — an operator wrote something and it does not parse.
218
+ function describeRoster(roster, { controlPlane = false } = {}) {
219
+ const r = roster || {};
220
+ const reason = ROSTER_REASONS.includes(r.reason) ? r.reason : ROSTER_EMPTY;
221
+ const where = r.source || SUBSCRIPTIONS_REL;
222
+ const dropped = Number(r.dropped) || 0;
223
+ const n = Array.isArray(r.instances) ? r.instances.length : 0;
224
+
225
+ if (reason === ROSTER_MALFORMED) {
226
+ return { level: 'alarm', reason, count: n, dropped,
227
+ message: `update-subscriptions: MALFORMED manifest at ${where} (${r.error || 'unparseable'}) — subscribing NOTHING. No instance will be upgraded until this parses.` };
228
+ }
229
+ if (reason === ROSTER_ABSENT) {
230
+ return { level: controlPlane ? 'alarm' : 'warn', reason, count: n, dropped,
231
+ message: `update-subscriptions: no manifest at ${where} — subscribing NOTHING.${controlPlane ? ' This is a CONTROL PLANE: it is supposed to keep instances current and currently keeps none.' : ' That is the shipped default; add an instance to enroll it.'}` };
232
+ }
233
+ if (reason === ROSTER_EMPTY) {
234
+ return { level: controlPlane ? 'alarm' : 'warn', reason, count: n, dropped,
235
+ message: `update-subscriptions: manifest at ${where} lists no upgradable instance${dropped ? ` (${dropped} entr${dropped === 1 ? 'y' : 'ies'} dropped for a missing \`dir\`)` : ''} — subscribing NOTHING.${controlPlane ? ' This is a CONTROL PLANE: it is supposed to keep instances current and currently keeps none.' : ''}` };
236
+ }
237
+ // Usable entries — still surface partial loss, which is the quiet half of an enrolled-but-untouched
238
+ // instance: the sweep looks healthy while the operator's broken row is never upgraded.
239
+ if (dropped > 0) {
240
+ return { level: 'warn', reason, count: n, dropped,
241
+ message: `update-subscriptions: ${n} instance${n === 1 ? '' : 's'} subscribed, but ${dropped} entr${dropped === 1 ? 'y was' : 'ies were'} DROPPED for a missing \`dir\` in ${where} — those instances are NOT being upgraded.` };
242
+ }
243
+ return { level: 'ok', reason, count: n, dropped,
244
+ message: `update-subscriptions: ${n} instance${n === 1 ? '' : 's'} subscribed from ${where}.` };
245
+ }
246
+
247
+ // ---- watching the watcher ---------------------------------------------------
248
+
249
+ // How far behind is this instance in releases its OWN channel is entitled to take? Not raw version
250
+ // distance: majors, prereleases and (on the patch channel) minors are releases the subscription is
251
+ // correctly declining, and counting them would alarm on instances that are behaving exactly as
252
+ // configured. `behind` is therefore "stable releases this channel WOULD have taken and has not".
253
+ //
254
+ // This is the check that was missing (task 1003158). deploy-divergence.js detects the SYMPTOM — live
255
+ // is behind main — at ship time, when a human is already looking. Nothing watched whether the
256
+ // upgrader itself was still alive, so its death was only ever discovered by someone noticing stale UI
257
+ // days later. A healthy sweep drives `behind` to 0 every run, so a persistent backlog IS the alarm.
258
+ //
259
+ // A `pinned` instance is never stale: channelAllows declines everything, so behind === 0 by
260
+ // construction. That is correct — a pinned instance is not neglected, it is pinned.
261
+ function subscriptionStaleness({ installed, channel, available, threshold = DEFAULT_STALENESS_THRESHOLD } = {}) {
262
+ const ch = normalizeChannel(channel);
263
+ const list = Array.isArray(available) ? available : [];
264
+ // `installed` is parsed and the channel re-normalized ONCE, not per candidate:
265
+ // channelAllows re-does both on every element, and this filter is the only place
266
+ // that calls it in a loop. Cheap either way at registry scale — hoisted because
267
+ // the invariant genuinely is invariant, not because the arithmetic hurts.
268
+ const from = parseSemver(installed);
269
+ const behind = (ch === 'pinned' || !from) ? 0
270
+ : list.filter((v) => channelAllows(installed, v, ch)).length;
271
+ const t = Number(threshold);
272
+ const limit = Number.isFinite(t) && t >= 0 ? t : DEFAULT_STALENESS_THRESHOLD;
273
+ return {
274
+ channel: ch,
275
+ installed: installed == null ? null : String(installed),
276
+ target: resolveChannelTarget({ installed, channel: ch, available: list }),
277
+ behind,
278
+ threshold: limit,
279
+ stale: behind > limit,
280
+ };
281
+ }
282
+
283
+ // The alarm line for one subscribed instance, or null when it is healthy (nothing to say).
284
+ function describeStaleness(st, { slug = 'instance' } = {}) {
285
+ if (!st || !st.stale) return null;
286
+ return `update-subscriptions: ${slug} is ${st.behind} ${st.channel} release${st.behind === 1 ? '' : 's'} behind `
287
+ + `(installed ${st.installed || 'unknown'}, entitled to ${st.target}) — over the ${st.threshold} threshold. `
288
+ + 'A healthy sweep clears this to zero every run, so the upgrader has most likely stopped.';
182
289
  }
183
290
 
184
291
  module.exports = {
@@ -195,4 +302,13 @@ module.exports = {
195
302
  listAvailableVersions,
196
303
  parseNpmViewVersions,
197
304
  loadSubscriptions,
305
+ ROSTER_OK,
306
+ ROSTER_ABSENT,
307
+ ROSTER_MALFORMED,
308
+ ROSTER_EMPTY,
309
+ ROSTER_REASONS,
310
+ DEFAULT_STALENESS_THRESHOLD,
311
+ describeRoster,
312
+ subscriptionStaleness,
313
+ describeStaleness,
198
314
  };
@@ -944,8 +944,11 @@ function mountInternalSurfaces(app) {
944
944
  // URL→gate map by parsing this file statically, and a comment in that gap drops
945
945
  // the regex out of its map — which reads as "every page on this tier is
946
946
  // ungated". It fails loudly by design; keep prose above the const.
947
+ // task 1003172 (C02): `collab` joins the same tier — every read behind it is
948
+ // any-builder and scoped to the caller by the SERVER (GET /help-requests/for-me
949
+ // derives crafts from the viewer's own profile), so signed-in is the right floor.
947
950
  const SIGNEDIN_BUILDERS_PAGE_RE =
948
- /^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
951
+ /^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|collab|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
949
952
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
950
953
  // page that embeds them.
951
954
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
package/src/module-api.js CHANGED
@@ -1122,8 +1122,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1122
1122
  // landed since 1.19.344 with no explicit bump. run 32800819617. (task 1002620)
1123
1123
  // 1.19.346 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1124
1124
  // landed since 1.19.345 with no explicit bump. run 32801147433. (task 1002620)
1125
+ // 1.19.347 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1126
+ // landed since 1.19.346 with no explicit bump. run 32802265337. (task 1002620)
1127
+ // 1.19.348 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1128
+ // landed since 1.19.347 with no explicit bump. run 32802746363. (task 1002620)
1125
1129
  // ---------------------------------------------------------------------------
1126
- const CORE_VERSION = '1.19.346'; // CI auto-patch carrier (ADR 0161); see changelog above
1130
+ const CORE_VERSION = '1.19.348'; // CI auto-patch carrier (ADR 0161); see changelog above
1127
1131
 
1128
1132
  // A namespaced logger so a module's log lines are attributable + consistent.
1129
1133
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,122 @@
1
+ // tests/client_baseurl_guard.mjs — the guard that stops a page 404ing every call
2
+ // (task 1003263).
3
+ //
4
+ // Three pages shipped the defect this guard exists to catch: ideas.js and idea.js
5
+ // were dead in production from the day they shipped, and collab.js was caught one
6
+ // commit before it would have joined them. So the guard itself is worth testing on
7
+ // synthetic sources rather than only on the live tree — a scan that passes because
8
+ // the tree happens to be clean today proves nothing about what it would REFUSE.
9
+ //
10
+ // Every case below is an in-memory file: no fixtures on disk, no dependence on the
11
+ // current state of modules/hall-ui/public/.
12
+ //
13
+ // Run: node tests/client_baseurl_guard.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const { checkClientBaseUrl, scanPages } = require('../scripts/gds/client-baseurl-guard.js');
20
+
21
+ let passed = 0;
22
+ let failed = 0;
23
+ function test(name, fn) {
24
+ try { fn(); passed++; console.log(` ok ${name}`); }
25
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
26
+ }
27
+
28
+ const from = (map) => ({
29
+ files: Object.keys(map),
30
+ read: (rel) => (rel in map ? map[rel] : null),
31
+ });
32
+
33
+ // --- THE DEFECT ------------------------------------------------------------
34
+
35
+ test('the exact shipped defect is refused: baseUrl:\'\' plus a namespaced call', () => {
36
+ const { violations } = scanPages(...Object.values(from({
37
+ 'modules/hall-ui/public/bad.js':
38
+ "const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin' });\n"
39
+ + 'const r = await api.inbox.getInboxTemplate({});\n',
40
+ })));
41
+ assert.equal(violations.length, 1);
42
+ assert.match(violations[0], /bad\.js/);
43
+ assert.match(violations[0], /site root/, 'the message says WHY it is wrong, not just that it is');
44
+ assert.match(violations[0], /Drop the baseUrl override/, 'and what to do about it');
45
+ });
46
+
47
+ test('double-quoted and backtick empty bases are the same defect', () => {
48
+ for (const empty of ['""', '``']) {
49
+ const { violations } = scanPages(...Object.values(from({
50
+ 'public/p.js': `const api = createClient({ baseUrl: ${empty} });\napi.me.getMe({});\n`,
51
+ })));
52
+ assert.equal(violations.length, 1, `baseUrl: ${empty} must be caught`);
53
+ }
54
+ });
55
+
56
+ // --- WHAT IT MUST NOT FLAG -------------------------------------------------
57
+ // A guard that fires on correct code gets suppressed, and then it protects nothing.
58
+
59
+ test("the escape-hatch style is CORRECT with baseUrl:'' — never flagged", () => {
60
+ // work.js, copy-desk.js and most of the hall do exactly this: empty base, but
61
+ // every path written out in full. Flagging it would be a false positive on the
62
+ // majority of the hall.
63
+ const { violations } = scanPages(...Object.values(from({
64
+ 'modules/hall-ui/public/work.js':
65
+ "const API = '/api/bongos';\n"
66
+ + "const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin' });\n"
67
+ + "const res = await api.request('GET', `${API}/tasks`);\n",
68
+ })));
69
+ assert.deepEqual(violations, []);
70
+ });
71
+
72
+ test('the fix itself passes — omitting baseUrl with namespaced calls is correct', () => {
73
+ const { violations } = scanPages(...Object.values(from({
74
+ 'modules/hall-ui/public/ideas.js':
75
+ "const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });\n"
76
+ + 'await api.inbox.getInboxTemplate({});\n',
77
+ })));
78
+ assert.deepEqual(violations, [], 'the shipped fix must not trip the guard that motivated it');
79
+ });
80
+
81
+ test('a non-empty baseUrl is a deliberate choice, not the defect', () => {
82
+ const { violations } = scanPages(...Object.values(from({
83
+ 'public/ext.js':
84
+ "const api = createClient({ baseUrl: 'https://other.example/api/bongos/v1' });\n"
85
+ + 'api.me.getMe({});\n',
86
+ })));
87
+ assert.deepEqual(violations, []);
88
+ });
89
+
90
+ test('a page that builds no client at all is ignored', () => {
91
+ const { violations, usingClient } = scanPages(...Object.values(from({
92
+ 'public/static.js': "document.querySelector('#x').textContent = 'hi';\n",
93
+ })));
94
+ assert.deepEqual(violations, []);
95
+ assert.equal(usingClient, 0);
96
+ });
97
+
98
+ // --- THE SCAN MUST NOT SILENTLY SUCCEED ------------------------------------
99
+
100
+ test('an empty enumeration is a hard FAIL, not a clean pass', () => {
101
+ // The failure mode that makes a guard worthless: git ls-files returns nothing,
102
+ // zero files are scanned, and the check reports green forever.
103
+ const r = checkClientBaseUrl({ files: [], read: () => null });
104
+ assert.equal(r.hardFail, true);
105
+ assert.match(r.violations[0], /scan defect/);
106
+ });
107
+
108
+ test('an unreadable file is skipped without being counted as scanned', () => {
109
+ const { scanned } = scanPages(['gone.js'], () => null);
110
+ assert.equal(scanned, 0);
111
+ });
112
+
113
+ test('the check reports hardFail so CI actually stops', () => {
114
+ const r = checkClientBaseUrl(from({
115
+ 'public/bad.js': "createClient({ baseUrl: '' });\napi.inbox.postInbox({});\n",
116
+ }));
117
+ assert.equal(r.ok, false);
118
+ assert.equal(r.hardFail, true, 'a page dead in prod is not a warning');
119
+ });
120
+
121
+ console.log(`\nclient_baseurl_guard: ${passed} passed, ${failed} failed`);
122
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,172 @@
1
+ // tests/collab_page.mjs — the Collab page (task 1003172 / C02, goal 1000075,
2
+ // criterion collab-page-matches-my-craft).
3
+ //
4
+ // The criterion has three clauses and the third is the one that gets skipped:
5
+ // requests match the viewer's CRAFT, are ANSWERABLE from the page, and a request
6
+ // nobody takes is VISIBLY AGING rather than silently dead. Sorting the oldest to
7
+ // the top does not satisfy the third — the oldest then looks exactly like the
8
+ // newest, only lower down.
9
+ import { strict as assert } from 'node:assert';
10
+ import { test } from 'node:test';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
17
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
18
+
19
+ // collab-lib.js is pure, so RUN it rather than grep it.
20
+ function lib() {
21
+ const src = read('modules', 'hall-ui', 'public', 'collab-lib.js');
22
+ const win = {};
23
+ new Function('window', src)(win);
24
+ return win.OTBCollabLib;
25
+ }
26
+ const NOW = Date.parse('2026-08-24T12:00:00Z');
27
+ const daysAgo = (d) => new Date(NOW - d * 86400000).toISOString();
28
+
29
+ // ---- THE AGEING CLAUSE ----------------------------------------------------
30
+ test('age is a BAND with a label, not just a sort position', () => {
31
+ const L = lib();
32
+ assert.equal(L.ageBand(daysAgo(0), NOW).id, 'fresh');
33
+ assert.equal(L.ageBand(daysAgo(1), NOW).id, 'waiting');
34
+ assert.equal(L.ageBand(daysAgo(3), NOW).id, 'aging');
35
+ assert.equal(L.ageBand(daysAgo(7), NOW).id, 'stale');
36
+ assert.equal(L.ageBand(daysAgo(90), NOW).id, 'stale');
37
+ for (const b of L.BANDS) assert.ok(b.label && b.label.length > 4, `${b.id} says it in words`);
38
+ });
39
+
40
+ test('the boundaries are exact, so the bands mean something', () => {
41
+ const L = lib();
42
+ assert.equal(L.ageBand(daysAgo(0.99), NOW).id, 'fresh', 'just under a day is still fresh');
43
+ assert.equal(L.ageBand(daysAgo(2.99), NOW).id, 'waiting');
44
+ assert.equal(L.ageBand(daysAgo(6.99), NOW).id, 'aging');
45
+ });
46
+
47
+ test('an unknown age reads as fresh, never as an accusation', () => {
48
+ const L = lib();
49
+ for (const bad of [null, undefined, '', 'nonsense']) {
50
+ assert.equal(L.ageBand(bad, NOW).id, 'fresh', `${JSON.stringify(bad)} must not look neglected`);
51
+ }
52
+ });
53
+
54
+ test('ageing changes how a row LOOKS, not only where it sits', () => {
55
+ // The whole point of the clause. Each band must have its own visual treatment
56
+ // in CSS, or "visibly aging" is just a sort order with extra steps.
57
+ const css = read('modules', 'hall-ui', 'public', 'collab.css');
58
+ const L = lib();
59
+ for (const b of L.BANDS) {
60
+ assert.match(css, new RegExp(`\\.cb-item--${b.id}\\b`), `${b.id} has its own styling`);
61
+ }
62
+ assert.match(css, /\.cb-item--stale\s*\{[^}]*border-left-color/, 'the band reads down a list at a glance');
63
+ // Colour alone is not a message and not everyone sees it.
64
+ assert.match(css, /\.cb-item--stale \.cb-age/, 'and the words are emphasised too');
65
+ });
66
+
67
+ test('the row markup carries the band class and the label', () => {
68
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
69
+ assert.match(js, /cb-item--\$\{L\.ageBand\(/, 'the class comes from the band');
70
+ assert.match(js, /cb-age cb-age--\$\{band\.id\}/, 'and so does the chip');
71
+ assert.match(js, /escapeHtml\(band\.label\)/, 'the label is rendered, escaped');
72
+ });
73
+
74
+ test('oldest first WITHIN the page, in addition to the band', () => {
75
+ const L = lib();
76
+ const rows = [{ created_at: daysAgo(1) }, { created_at: daysAgo(9) }, { created_at: daysAgo(4) }];
77
+ const sorted = rows.slice().sort(L.byAgeOldestFirst);
78
+ assert.deepEqual(sorted.map((r) => L.ageBand(r.created_at, NOW).id), ['stale', 'aging', 'waiting']);
79
+ });
80
+
81
+ // ---- THE MATCHING CLAUSE --------------------------------------------------
82
+ test('the page NEVER sends a craft — matching is the server\'s job', () => {
83
+ // C01's grader found a ?craft= param that let any builder read another craft's
84
+ // requests. The fix put craft derivation on the server, and this page must not
85
+ // reintroduce the parameter from the client side.
86
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
87
+ // Assert on the REQUEST, not on any occurrence of the word: the first version
88
+ // matched `Matching your craft: ${crafts.join(', ')}` — the page's own subtitle —
89
+ // which is display text, not a parameter. Fifth time this session that a source
90
+ // assertion has been wider than its invariant (learning 1000196).
91
+ assert.match(js, /getHelpRequestsForMe\(\{\}\)/, 'the read is unparameterised');
92
+ assert.ok(!/(query|body):\s*\{[^}]*craft/.test(js), 'craft never travels in a query or body');
93
+ });
94
+
95
+ test('a craft request and a by-name request read differently', () => {
96
+ const L = lib();
97
+ assert.match(L.addressedTo({ needs_craft: 'artist' }, 1), /artists/);
98
+ assert.match(L.addressedTo({ needs_builder_id: 7 }, 7), /by name/);
99
+ assert.match(L.addressedTo({ needs_builder_id: 7 }, 9), /one person/);
100
+ });
101
+
102
+ test('context links to the task or goal, and to neither when absent', () => {
103
+ const L = lib();
104
+ assert.equal(L.contextOf({ task_id: 5 }).kind, 'task');
105
+ assert.equal(L.contextOf({ goal_id: 5 }).kind, 'goal');
106
+ assert.equal(L.contextOf({}), null, 'no context means no element, not an empty one');
107
+ });
108
+
109
+ // ---- THE ANSWERABLE CLAUSE ------------------------------------------------
110
+ test('the asker can settle in place, with both outcomes', () => {
111
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
112
+ assert.match(js, /data-settle="answered"/);
113
+ assert.match(js, /data-settle="withdrawn"/);
114
+ assert.match(js, /patchHelpRequestsId/, 'through the real API, not a local edit');
115
+ });
116
+
117
+ test('there is deliberately NO reply thread — that would reimplement the Agora', () => {
118
+ // The goal forbids reimplementing the Agora to get around C06's dependency.
119
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
120
+ for (const f of ['reply', 'comment', 'thread', 'message']) {
121
+ assert.ok(!new RegExp(`data-${f}|post${f}`, 'i').test(js), `no ${f} surface`);
122
+ }
123
+ });
124
+
125
+ // ---- placement ------------------------------------------------------------
126
+ test('it is a PAGE, not a Home card — the actionable half is the needs roster', () => {
127
+ // hall-ui/CLAUDE.md's tree: an action awaiting the builder goes in the
128
+ // needs-attention inbox and NEVER a new card, because one landing place for
129
+ // everything actionable is the whole point. C01 already put it there.
130
+ assert.ok(fs.existsSync(path.join(HALL, 'collab.html')));
131
+ const builders = read('modules', 'hall-ui', 'public', 'builders.js');
132
+ assert.ok(!/collab/i.test(builders), 'no Home card was added');
133
+ });
134
+
135
+ test('the page is behind the signed-in gate', () => {
136
+ const serve = read('src', 'bongos', 'routes', '..', 'serve-internal.js');
137
+ const m = /const SIGNEDIN_BUILDERS_PAGE_RE =\s*(\/[^\n]+\/);/.exec(serve);
138
+ assert.ok(m, 'the gate const is still statically parseable');
139
+ assert.ok(new RegExp(m[1].slice(1, -1)).test('/builders/collab'), 'collab is gated');
140
+ });
141
+
142
+ test('every CSS token the page uses exists in style.css', () => {
143
+ const css = read('modules', 'hall-ui', 'public', 'collab.css');
144
+ const style = read('modules', 'hall-ui', 'public', 'style.css');
145
+ const used = new Set([...css.matchAll(/var\((--[a-z-]+)\)/g)].map((x) => x[1]));
146
+ assert.ok(used.size >= 5);
147
+ for (const t of used) {
148
+ assert.ok(new RegExp(`^\\s*${t}:`, 'm').test(style), `${t} must be defined (idea 1000821)`);
149
+ }
150
+ });
151
+
152
+ // ---- THE CLIENT ACTUALLY REACHES THE API ----------------------------------
153
+ // The page called every endpoint through the GENERATED client (api.helpRequests.*),
154
+ // whose methods emit API-RELATIVE paths ('/help-requests/for-me'). Combined with
155
+ // baseUrl:'' — which the client documents as "these paths are already absolute" —
156
+ // every request went to the SITE ROOT and 404'd, so the page would have shipped
157
+ // dead on arrival. ideas.js and idea.js already shipped with exactly this defect.
158
+ //
159
+ // Grepped rather than executed on purpose: the defect is in URL CONSTRUCTION, and
160
+ // every existing test stubs the client, which is precisely why three pages shipped
161
+ // green while 404ing. A repo-wide version of this guard belongs in fitness.js once
162
+ // the two idea pages are fixed — scoped here to the page this task owns.
163
+ test('the client is not configured to send API calls to the site root', () => {
164
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
165
+ const namespaced = [...js.matchAll(/\bapi\.([a-zA-Z]\w*)\.([a-zA-Z]\w*)\(/g)];
166
+ assert.ok(namespaced.length > 0, 'this page uses the generated client');
167
+ assert.ok(
168
+ !/createClient\(\{[^}]*baseUrl:\s*(''|"")/.test(js),
169
+ "baseUrl:'' sends generated-client paths to the site root — drop it so they "
170
+ + 'resolve against /api/bongos/v1, or switch every call to api.request() with a full path',
171
+ );
172
+ });
@@ -149,7 +149,13 @@ test('sidebar groups read by audience, in order', () => {
149
149
  // Gated cosmetically to match its data endpoint — `project.curate`, floor METIC,
150
150
  // so the nav entry reads 'government' since ADR 0174 (it said 'archon', then
151
151
  // was stricter than the wall). requireProjectCuratePage is the wall server-side.
152
- assert.deepEqual(nav[2].items.map((i) => i.id), ['leaderboard', 'people'], 'Community = everyone else');
152
+ // task 1003172 (C02 of goal 1000075): 'collab' joins Community, FIRST. The
153
+ // group is picked by whose data it is, and a help request is another builder
154
+ // reaching you — not your own numbers (that is You), and not reference (Docs).
155
+ // First within the group because it is the one page here you ACT on; the
156
+ // leaderboard and the directory are browsing surfaces. Updating this oracle is
157
+ // the reviewed decision the pin exists to force.
158
+ assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'leaderboard', 'people'], 'Community = everyone else');
153
159
  assert.deepEqual(nav[3].items.map((i) => i.id),
154
160
  ['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
155
161
  // 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
@@ -112,6 +112,17 @@ await test('the page gets both the FORM and the METER from the server', () => {
112
112
  assert.match(IDEAS_JS, /getInboxTemplate/, 'the form is rendered from GET /inbox/template');
113
113
  assert.match(IDEAS_JS, /postInboxScore/, 'the meter is filled from POST /inbox/score');
114
114
  assert.match(IDEAS_JS, /postInbox\b/, 'and it files through POST /inbox');
115
+
116
+ // ...AND THOSE CALLS MUST REACH THE API. The three assertions above are regexes
117
+ // over the SOURCE TEXT, so they passed for months while the page 404'd on every
118
+ // one of them: baseUrl:'' told the client the API-relative templates were already
119
+ // absolute, and each request went to the site root. Naming a method proves the
120
+ // call is written, never that it is addressed — task 1003263.
121
+ assert.doesNotMatch(
122
+ IDEAS_JS,
123
+ /createClient\(\s*\{[^}]*baseUrl\s*:\s*(''|"")/,
124
+ "baseUrl:'' sends the generated client's API-relative paths to the site root",
125
+ );
115
126
  });
116
127
 
117
128
  await test('the page does not print a pass bar — that rule lives in economy', () => {