@bongos/core 1.19.533 → 1.19.535

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 (62) hide show
  1. package/.bongos-core.json +121 -56
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/config/branding.neutral.json +3 -2
  4. package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
  5. package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
  6. package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
  7. package/docs/adr/README.md +4 -0
  8. package/docs/api/openapi.json +8 -1
  9. package/docs/api-reference.md +1 -1
  10. package/docs/copy-inventory.md +162 -157
  11. package/docs/copy-registry.json +252 -196
  12. package/docs/file-map.md +1 -0
  13. package/docs/module-api-changelog.md +16 -0
  14. package/modules/hall-ui/public/hall-kit.js +93 -7
  15. package/modules/hall-ui/public/roadmap-timeline.js +105 -0
  16. package/modules/hall-ui/public/roadmap.html +7 -1
  17. package/modules/hall-ui/public/roadmap.js +16 -0
  18. package/modules/hall-ui/public/style.css +4 -2
  19. package/modules/hall-ui/public/watch.html +4 -0
  20. package/modules/hall-ui/public/watch.js +116 -0
  21. package/modules/onboarding/module.json +1 -1
  22. package/modules/onboarding/routes/access-requests.js +21 -6
  23. package/modules/platform-identity/module.json +1 -1
  24. package/modules/platform-identity/project-catalog.js +202 -0
  25. package/modules/platform-identity/routes/my-projects.js +7 -2
  26. package/modules/platform-identity/routes/projects.js +6 -0
  27. package/modules/platform-identity/routes/sso.js +10 -0
  28. package/modules/provisioning/catalog-bridge.js +105 -0
  29. package/modules/provisioning/provisioning.js +28 -2
  30. package/modules/provisioning/routes/provisioning.js +45 -11
  31. package/modules/provisioning/tests/provisioning.mjs +11 -8
  32. package/modules/public-landing/public/assets/cosmos.css +15 -0
  33. package/modules/public-landing/public/projects.html +162 -3
  34. package/modules/ui-design/kit/serve.js +1 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/fitness.js +9 -0
  38. package/scripts/gds/provision-net.js +7 -0
  39. package/scripts/hall-preview/refresh-fixtures.js +6 -0
  40. package/src/bongos/project-door.js +138 -0
  41. package/src/branding.js +8 -0
  42. package/src/module-api.js +12 -1
  43. package/tests/access_requests_invite.mjs +2 -2
  44. package/tests/application_lifecycle.mjs +1 -0
  45. package/tests/catalog_only_client.mjs +242 -0
  46. package/tests/currency_label.mjs +8 -5
  47. package/tests/hall_kit_charts.mjs +173 -0
  48. package/tests/hall_oversight_world.mjs +159 -0
  49. package/tests/hall_reading_world.mjs +153 -0
  50. package/tests/module_api.mjs +1 -0
  51. package/tests/project_catalog_bridge.mjs +355 -0
  52. package/tests/project_catalog_routes.mjs +213 -0
  53. package/tests/project_join_door.mjs +166 -0
  54. package/tests/project_join_door_route.mjs +206 -0
  55. package/tests/project_modules_ui.mjs +318 -0
  56. package/tests/projects_hub.mjs +6 -1
  57. package/tests/projects_hub_pre_uat.mjs +2 -2
  58. package/tests/provision_settings_apply.mjs +6 -5
  59. package/tests/provisioning_module_selection.mjs +164 -3
  60. package/tests/provisioning_settings.mjs +10 -7
  61. package/tests/provisioning_settings_apply.mjs +3 -3
  62. package/tests/provisioning_settings_env.mjs +7 -4
@@ -0,0 +1,242 @@
1
+ // tests/catalog_only_client.mjs — A CATALOG-ONLY CLIENT IS AN IDENTITY, NEVER A CREDENTIAL
2
+ // (task 1003576, ADR 0246). The security half of the catalog bridge.
3
+ //
4
+ // The bridge mints an `platform_identity_sso_clients` row for EVERY control-plane project so
5
+ // the map, the membership directory and `POST /projects/invite` have something to name — a
6
+ // large increase in the number of registered clients. This file is the proof that the row it
7
+ // mints can do nothing but be named: it carries an EMPTY secret_hash and an EMPTY
8
+ // redirect-URI allow-list, and every SSO leg refuses it.
9
+ //
10
+ // Four of the five legs already fail closed on machinery that predates this task —
11
+ // clientSecretValid() on a secretless client, redirectUriAllowed() on an empty allow-list —
12
+ // and that is exactly why those are asserted here rather than assumed: they are load-bearing
13
+ // for a decision made after they were written. The fifth, /sso/device/start, resolved a
14
+ // client from a typed origin and checked NEITHER, so it grew an explicit guard.
15
+ //
16
+ // Also pinned: the invite route stops 404ing for a wizard-made project (task 1003580's
17
+ // GAP 1). Its `project.curate` gate is untouched and is 1003580's own work.
18
+ //
19
+ // DB-free: the real routers driven over loopback, with the data-access functions stubbed on
20
+ // the cached module objects (the provisioning_settings.mjs pattern).
21
+ //
22
+ // Run: node --test --test-reporter=tap tests/catalog_only_client.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test, after } from 'node:test';
26
+ import { createRequire } from 'node:module';
27
+ import http from 'node:http';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const pi = require('../modules/platform-identity/platform-identity.js');
31
+ const catalog = require('../modules/platform-identity/project-catalog.js');
32
+ const hubKeys = require('../modules/platform-identity/hub-keys.js');
33
+ const hubDevice = require('../modules/platform-identity/hub-device.js');
34
+ const api = require('../src/module-api.js');
35
+
36
+ // The row the bridge writes: named, active, and holding nothing it could authenticate with.
37
+ const CATALOG_ONLY = Object.freeze({
38
+ client_id: 'spike-harbor', secret_hash: '', name: 'spike-harbor',
39
+ redirect_uris: [], origin: 'https://spike-harbor.cloudbongos.test',
40
+ owner_github_id: 9001, status: 'active',
41
+ });
42
+ // A real federated client, for the contrast — the same shape with a credential.
43
+ const FEDERATED = Object.freeze({
44
+ ...CATALOG_ONLY, client_id: 'mercury', secret_hash: 'a'.repeat(64),
45
+ redirect_uris: ['https://mercury.test/api/gds/auth/web/callback'], origin: 'https://mercury.test',
46
+ });
47
+
48
+ // ── the predicate itself ─────────────────────────────────────────────────────
49
+
50
+ test('clientIsCredentialed separates "the hub can name it" from "it may sign people in"', () => {
51
+ assert.equal(catalog.clientIsCredentialed(FEDERATED), true);
52
+ assert.equal(catalog.clientIsCredentialed(CATALOG_ONLY), false);
53
+ for (const secret_hash of [null, undefined, '']) {
54
+ assert.equal(catalog.clientIsCredentialed({ ...FEDERATED, secret_hash }), false);
55
+ }
56
+ assert.equal(catalog.clientIsCredentialed(null), false, 'no client is not a credentialed one');
57
+ });
58
+
59
+ test('clientSecretValid fails closed on a secretless client, whatever is presented', () => {
60
+ // The load-bearing pre-existing guard: it hashes and constant-time-compares against a decoy
61
+ // when there is no real secret, then refuses because there was none. So the empty string,
62
+ // the literal decoy input and a lucky guess all lose.
63
+ for (const presented of ['', 'anything', 'a'.repeat(64), 'spike-harbor']) {
64
+ assert.equal(pi.clientSecretValid(CATALOG_ONLY, presented), false);
65
+ }
66
+ assert.equal(pi.clientSecretValid(FEDERATED, 'wrong'), false, 'and a real client still needs the real secret');
67
+ });
68
+
69
+ test('redirectUriAllowed fails closed on an empty allow-list', () => {
70
+ assert.equal(pi.redirectUriAllowed(CATALOG_ONLY, 'https://spike-harbor.cloudbongos.test/api/gds/auth/web/callback'), false);
71
+ assert.equal(pi.redirectUriAllowed(CATALOG_ONLY, ''), false);
72
+ assert.equal(pi.redirectUriAllowed(FEDERATED, FEDERATED.redirect_uris[0]), true);
73
+ });
74
+
75
+ // ── the routes, executed ─────────────────────────────────────────────────────
76
+
77
+ // The auth middleware is DESTRUCTURED off the doorway at route-module load, so these stubs
78
+ // have to be in place before the routers are required.
79
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'curator', rank: 'metic' }; next(); };
80
+ api.requirePermission = () => (_req, _res, next) => next();
81
+
82
+ const express = require('express');
83
+ const ssoRoutes = require('../modules/platform-identity/routes/sso.js');
84
+ const projectsRoutes = require('../modules/platform-identity/routes/projects.js');
85
+
86
+ // The routes read the hub's own config through these; the SSO legs 503 without them, which
87
+ // would hide the refusals under test.
88
+ hubKeys.hubSigningConfigured = () => true;
89
+ hubKeys.hubPublicKeyB64 = () => 'stub-public-key';
90
+ hubDevice.hubDeviceConfigured = () => true;
91
+ let deviceStarts = 0;
92
+ hubDevice.startHubGithubDevice = async () => { deviceStarts++; return { device_code: 'd', user_code: 'U' }; };
93
+
94
+ // The client registry, stubbed on the cached module object.
95
+ const CLIENTS = new Map([[CATALOG_ONLY.client_id, CATALOG_ONLY], [FEDERATED.client_id, FEDERATED]]);
96
+ pi.getClient = async (id) => CLIENTS.get(id) || null;
97
+ pi.getClientByOrigin = async (origin) => {
98
+ const norm = String(origin || '').replace(/\/+$/, '');
99
+ return [...CLIENTS.values()].find((c) => c.origin === norm && c.status === 'active') || null;
100
+ };
101
+ // The invite leg's two other reads — the point is which refusal the route reaches, not what
102
+ // a real membership write does.
103
+ pi.getAccountByLogin = async (login) => (login === 'ghost' ? null : { github_id: 5150, github_login: login });
104
+ pi.inviteToProject = async ({ clientId, githubId }) => ({ invited: true, clientId, githubId });
105
+
106
+ const app = express();
107
+ app.use(express.json());
108
+ app.use((_req, res, next) => {
109
+ res.fail = (code, opts, details) => {
110
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
111
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
112
+ };
113
+ next();
114
+ });
115
+ app.use('/api/bongos', ssoRoutes());
116
+ app.use('/api/bongos', projectsRoutes());
117
+
118
+ const server = app.listen(0);
119
+ await new Promise((r) => server.once('listening', r));
120
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
121
+ after(() => server.close());
122
+
123
+ function call(method, path, body) {
124
+ return new Promise((resolve, reject) => {
125
+ const payload = body === undefined ? null : JSON.stringify(body);
126
+ const req = http.request(`${base}${path}`, {
127
+ method,
128
+ headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {},
129
+ }, (res) => {
130
+ let d = '';
131
+ res.on('data', (c) => { d += c; });
132
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* html */ } resolve({ status: res.statusCode, body: j, raw: d }); });
133
+ });
134
+ req.on('error', reject);
135
+ if (payload) req.write(payload);
136
+ req.end();
137
+ });
138
+ }
139
+
140
+ test('/sso/authorize REFUSES a catalog-only client — no redirect_uri is registered for it', async () => {
141
+ // The open-redirect guard doing double duty: an empty allow-list means every candidate
142
+ // callback fails exact match, so the browser leg never mints a code and never redirects.
143
+ const uri = 'https://spike-harbor.cloudbongos.test/api/gds/auth/web/callback';
144
+ const res = await call('GET', `/sso/authorize?client_id=spike-harbor&redirect_uri=${encodeURIComponent(uri)}`);
145
+ assert.equal(res.status, 400);
146
+ assert.match(res.raw, /redirect_uri is not registered/);
147
+ assert.doesNotMatch(res.raw, /code=/, 'no authorization code may appear on any refusal path');
148
+ });
149
+
150
+ test('/sso/token REFUSES a catalog-only client for every secret it could be handed', async () => {
151
+ for (const client_secret of ['', 'guess', 'spike-harbor', 'a'.repeat(64)]) {
152
+ const res = await call('POST', '/sso/token', {
153
+ client_id: 'spike-harbor', client_secret: client_secret || 'x',
154
+ code: 'c', redirect_uri: 'https://spike-harbor.cloudbongos.test/api/gds/auth/web/callback',
155
+ });
156
+ assert.equal(res.status, 401, `secret "${client_secret}" must not exchange`);
157
+ assert.equal(res.body.error.code, 'invalid_client');
158
+ }
159
+ });
160
+
161
+ test('/sso/device/start REFUSES a catalog-only origin — the leg that checked neither guard', async () => {
162
+ // This is the ONE that needed new code. It resolves the client from the USER-TYPED origin
163
+ // and checked only `status`, so without the guard every catalogued project would have had a
164
+ // device flow nobody registered a credential for.
165
+ deviceStarts = 0;
166
+ const res = await call('POST', '/sso/device/start', { origin: 'https://spike-harbor.cloudbongos.test' });
167
+ assert.equal(res.status, 401);
168
+ assert.equal(res.body.error.code, 'invalid_client');
169
+ assert.equal(deviceStarts, 0, 'and the GitHub device flow is never even started');
170
+ });
171
+
172
+ test('/sso/device/start still serves a real federated client', async () => {
173
+ // The guard must refuse the new row shape and nothing else.
174
+ deviceStarts = 0;
175
+ const res = await call('POST', '/sso/device/start', { origin: 'https://mercury.test' });
176
+ assert.equal(res.status, 200);
177
+ assert.equal(res.body.user_code, 'U');
178
+ assert.equal(deviceStarts, 1);
179
+ });
180
+
181
+ test('/sso/membership/check-in REFUSES a catalog-only client', async () => {
182
+ const res = await call('POST', '/sso/membership/check-in', {
183
+ client_id: 'spike-harbor', client_secret: 'x', github_id: 5150, github_login: 'someone',
184
+ });
185
+ assert.equal(res.status, 401);
186
+ assert.equal(res.body.error.code, 'invalid_client');
187
+ });
188
+
189
+ test('/sso/activity/rollup REFUSES a catalog-only client', async () => {
190
+ const res = await call('POST', '/sso/activity/rollup', {
191
+ client_id: 'spike-harbor', client_secret: 'x', github_id: 5150, credits: 999, rank: 'archon',
192
+ });
193
+ assert.equal(res.status, 401);
194
+ assert.equal(res.body.error.code, 'invalid_client');
195
+ });
196
+
197
+ // ── what the catalog row DOES buy: a resolvable invite target (task 1003580) ──
198
+
199
+ test('POST /projects/invite no longer 404s for a wizard-made project', async () => {
200
+ // GAP 1 of task 1003580: the route resolves its target with getClient() and refused
201
+ // `unknown_project` because a standalone project was never an sso_clients row at all. It
202
+ // now is. The route's `project.curate` gate is deliberately untouched — widening it to the
203
+ // project's own owner is 1003580's job, and this test stubs the permission middleware so
204
+ // the assertion is about the TARGET resolving, not about who may call it.
205
+ const res = await call('POST', '/projects/invite', { client_id: 'spike-harbor', github_login: 'someone' });
206
+ assert.notEqual(res.status, 404);
207
+ assert.equal(res.status, 201);
208
+ assert.equal(res.body.invited, true);
209
+ assert.equal(res.body.clientId, 'spike-harbor');
210
+ });
211
+
212
+ test('an unknown project still 404s — the catalog row is what resolves, not the route', async () => {
213
+ const res = await call('POST', '/projects/invite', { client_id: 'never-provisioned', github_login: 'someone' });
214
+ assert.equal(res.status, 404);
215
+ assert.equal(res.body.error.code, 'unknown_project');
216
+ });
217
+
218
+ // ── the seam the write half travels on ───────────────────────────────────────
219
+
220
+ test('mounting the projects routes registers the `project.catalog` port', async () => {
221
+ // The write half of the same seam `project.publishGate` is the read half of. provisioning
222
+ // owns these projects and may not import this module (ADR 0083), so the switchboard is how
223
+ // the projection reaches the tables that hold the map.
224
+ assert.equal(api.hasProvider('project.catalog'), true);
225
+ assert.equal(api.resolve('project.catalog'), catalog.projectCatalogPort);
226
+ assert.equal(typeof api.resolve('project.catalog').publish, 'function');
227
+ assert.ok(Object.isFrozen(catalog.projectCatalogPort), 'a consumer must not be able to re-point the port');
228
+ });
229
+
230
+ test('the port declares the catalog write and nothing else', () => {
231
+ // One verb. A consumer maps its own row onto the entry shape, so the catalog never learns
232
+ // another module’s schema and no module gets a general-purpose handle on these tables.
233
+ assert.deepEqual(Object.keys(catalog.projectCatalogPort), ['publish']);
234
+ });
235
+
236
+ test('the module manifest DECLARES the port it registers', async () => {
237
+ // verifyPortsSatisfied reads the manifests at boot, not the registry — an undeclared
238
+ // provider is invisible to it.
239
+ const manifest = JSON.parse(await (await import('node:fs/promises')).readFile(
240
+ new URL('../modules/platform-identity/module.json', import.meta.url), 'utf8'));
241
+ assert.ok(manifest.provides.includes('project.catalog'));
242
+ });
@@ -48,11 +48,14 @@ test('R57: clientBranding exposes theme.ui (web), NOT theme.palette (server-only
48
48
  });
49
49
 
50
50
  test('clientBranding exposes project policy — the settings read-back (task 1003140), defaulting to today\'s behavior', () => {
51
- // `visibility` — the join door — joined the block in task 1002324 (ADR 0182 D1)
52
- assert.deepEqual(clientBranding({}).project, { platformVisibility: 'public', joinability: 'apply', visibility: 'public' });
51
+ // `visibility` — the join door — joined the block in task 1002324 (ADR 0182 D1);
52
+ // `joinGrant` — what a PUBLIC join grants — in task 1002331 (ADR 0247), defaulting
53
+ // to `full`, the value that narrows nothing, so an un-pushed instance is unchanged
54
+ assert.deepEqual(clientBranding({}).project,
55
+ { platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full' });
53
56
  assert.deepEqual(
54
- clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' } }).project,
55
- { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' },
57
+ clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view' } }).project,
58
+ { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view' },
56
59
  'what the instance actually resolved (env override included) is what it publishes');
57
60
  });
58
61
 
@@ -71,7 +74,7 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
71
74
  // `project` joined the allow-list in task 1003140: the per-project policy is public
72
75
  // by nature and is the settings read-back a manage page needs (see clientBranding).
73
76
  assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'project', 'rankLabels', 'reward', 'theme']);
74
- assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility', 'visibility']);
77
+ assert.deepEqual(Object.keys(projected.project).sort(), ['joinGrant', 'joinability', 'platformVisibility', 'visibility']);
75
78
  for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
76
79
  assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
77
80
  }
@@ -349,6 +349,10 @@ test('the kit is loaded before every page that draws through it', () => {
349
349
  ['index.html', 'hall-render.js'],
350
350
  ['watch.html', 'watch.js'],
351
351
  ['profile.html', 'profile-charts.js'],
352
+ // task 1003447: the roadmap's own series module reads the kit, and the page
353
+ // reads that. Both edges of the chain are ordered here.
354
+ ['roadmap.html', 'roadmap-timeline.js'],
355
+ ['roadmap.html', 'roadmap.js'],
352
356
  ]) {
353
357
  // The loaded ORDER, read off the script tags — index.html's prose mentions
354
358
  // hall-render.js ~90 lines above where it loads it, so a substring search
@@ -375,3 +379,172 @@ test('the migrated pages run for real against the kit and emit its classes', ()
375
379
  /class="kit-bars"/,
376
380
  );
377
381
  });
382
+
383
+ // ---- 5. the SPAN bar and the fitted axis (task 1003447) --------------------
384
+ //
385
+ // Two options, both added so the goal's last two charts needed no new drawing
386
+ // code and no new class family: a bar that starts somewhere other than the
387
+ // track's left edge (which is what makes a bar series a TIMELINE), and a plot
388
+ // whose axis sits at something other than zero (which is what makes a step line
389
+ // readable for a LEVEL rather than a running total).
390
+
391
+ test('a row\'s `start` offsets the bar and leaves its length alone', () => {
392
+ // The roadmap's shape: version A ran for the first quarter of the axis,
393
+ // version B for the middle half. Same primitive, same classes.
394
+ const html = kit.barSeriesHtml([
395
+ { label: 'A', value: 25 },
396
+ { label: 'B', value: 50, start: 25 },
397
+ ], { max: 100 });
398
+ const fills = [...html.matchAll(/class="kit-bars__fill" style="([^"]+)"/g)].map((m) => m[1]);
399
+ assert.deepEqual(fills, ['width:25%', 'margin-left:25%;width:50%']);
400
+ assert.equal((html.match(/class="kit-bars"/g) || []).length, 1, 'still one class family');
401
+ });
402
+
403
+ test('a bar with no `start` writes no offset at all', () => {
404
+ // The compatibility contract: every caller that predates the span option gets
405
+ // byte-identical markup, so `margin-left:0%` never appears in a diff or in a
406
+ // snapshot of an existing chart.
407
+ const html = kit.barSeriesHtml([{ label: 'a', value: 3 }, { label: 'b', value: 1, start: 0 }]);
408
+ assert.doesNotMatch(html, /margin-left/);
409
+ // A nonsense start is ignored rather than written through into the style.
410
+ const junk = kit.barSeriesHtml([{ label: 'a', value: 3, start: 'nope' }], { max: 10 });
411
+ assert.doesNotMatch(junk, /margin-left/);
412
+ assert.doesNotMatch(junk, /NaN/);
413
+ });
414
+
415
+ test('a tiny non-zero `start` still offsets, so "first" stays a real claim', () => {
416
+ // The offset takes the same floor the width does. Without it a span beginning
417
+ // just after the axis origin rounds to 0% and draws flush left — which on a
418
+ // timeline reads as "this one started first", a claim about ordering rather
419
+ // than a rounding artefact.
420
+ const html = kit.barSeriesHtml([
421
+ { label: 'first', value: 500 },
422
+ { label: 'barely later', value: 500, start: 1 },
423
+ ], { max: 1000 });
424
+ const fills = [...html.matchAll(/class="kit-bars__fill" style="([^"]+)"/g)].map((m) => m[1]);
425
+ assert.equal(fills[0], 'width:50%', 'the one at the origin carries no offset');
426
+ assert.match(fills[1], /^margin-left:1%/, 'and the one after it is visibly after it');
427
+ });
428
+
429
+ test('a positive value never draws nothing, however small its share', () => {
430
+ // Rounding alone sends anything under half a percent to `width:0%`, which
431
+ // reads as "none" — the one thing it is not. Load-bearing for the roadmap: a
432
+ // version that started and shipped inside one day is a real span.
433
+ const html = kit.barSeriesHtml([{ label: 'tiny', value: 1 }, { label: 'huge', value: 100000 }]);
434
+ assert.match(html, /width:1%/, 'the sliver survives');
435
+ // and zero is still zero: an empty track is the honest picture of nothing.
436
+ assert.match(kit.barSeriesHtml([{ label: 'a', value: 0 }, { label: 'b', value: 9 }]), /width:0%/);
437
+ });
438
+
439
+ test('plotWindow puts a reachable-zero series on a zero floor', () => {
440
+ const w = kit.plotWindow([0, 3, 1, 4]);
441
+ assert.equal(w.yMin, 0, 'a count sits on zero — the distance from it IS the reading');
442
+ assert.ok(w.yMax > 4, 'with air above, so the peak does not ride the top edge');
443
+ assert.equal(w.lo, 0);
444
+ assert.equal(w.hi, 4);
445
+ });
446
+
447
+ test('plotWindow fits the floor for a level, which is why the caller must say so', () => {
448
+ const w = kit.plotWindow([24880, 27195], { zeroBased: false });
449
+ assert.ok(w.yMin > 24000 && w.yMin < 24880, 'the axis rises to just under the minimum');
450
+ assert.ok(w.yMax > 27195);
451
+ // The whole point: on a zero floor this month of change would occupy the top
452
+ // 9% of the box. Fitted, it occupies the whole of it.
453
+ const zero = kit.plotWindow([24880, 27195]);
454
+ assert.ok((w.yMax - w.yMin) < (zero.yMax - zero.yMin) / 5, 'the fitted window is far tighter');
455
+ });
456
+
457
+ test('plotWindow survives a flat series and an empty one', () => {
458
+ // A flat series has no range to take a fraction of, and dividing by it is how
459
+ // a chart renders as an invisible NaN.
460
+ const flat = kit.plotWindow([5, 5, 5]);
461
+ assert.ok(flat.yMax > 5, 'flat still gets air, so the line is not welded to the top');
462
+ const flatFit = kit.plotWindow([5, 5, 5], { zeroBased: false });
463
+ assert.ok(flatFit.yMin < 5 && flatFit.yMax > 5, 'and a fitted flat line lands mid-box');
464
+ const zeros = kit.plotWindow([0, 0, 0]);
465
+ assert.equal(zeros.yMin, 0);
466
+ assert.ok(zeros.yMax > 0, 'a month of honest zeros still divides by something');
467
+ assert.deepEqual(kit.plotWindow([]), { lo: 0, hi: 0, yMin: 0, yMax: 1, n: 0 });
468
+ });
469
+
470
+ test('plotWindow DROPS a non-finite reading rather than counting it as zero', () => {
471
+ // The trap this exists to close: Number(null) is 0 and finite, so a snapshot
472
+ // that predates a column would drag `lo` to zero and draw a cliff.
473
+ const w = kit.plotWindow([100, null, 120, undefined, 110]);
474
+ assert.equal(w.lo, 100);
475
+ assert.equal(w.n, 3, 'three readings, not five');
476
+ });
477
+
478
+ test('yMin lifts the axis, and its default leaves every existing series where it was', () => {
479
+ const box = { width: 100, height: 100, pad: { l: 0, r: 0, t: 0, b: 0 } };
480
+ const pts = [{ x: 0, value: 10 }, { x: 10, value: 20 }];
481
+ // Default (0): 20 of a 20-high window is the top, 10 is halfway.
482
+ const zero = kit.timeStepGeometry(pts, { x0: 0, x1: 10, yMax: 20, ...box });
483
+ assert.equal(zero.py(20).toFixed(1), '0.0');
484
+ assert.equal(zero.py(10).toFixed(1), '50.0');
485
+ // Fitted to 10..20: the same two points now span the whole box.
486
+ const fitted = kit.timeStepGeometry(pts, { x0: 0, x1: 10, yMin: 10, yMax: 20, ...box });
487
+ assert.equal(fitted.py(20).toFixed(1), '0.0');
488
+ assert.equal(fitted.py(10).toFixed(1), '100.0');
489
+ // A degenerate window (yMin === yMax) must not divide by zero.
490
+ const flat = kit.timeStepGeometry(pts, { x0: 0, x1: 10, yMin: 5, yMax: 5, ...box });
491
+ assert.doesNotMatch(flat.d, /NaN|Infinity/);
492
+ });
493
+
494
+ test('RUN: the roadmap timeline draws the real versions as spans on one axis', async () => {
495
+ // The page's own series module, run against the checked-in /public/versions
496
+ // fixture — the arithmetic a source assertion cannot reach. It is imported
497
+ // rather than stubbed for the same reason the kit is: a copy of the maths
498
+ // agreeing with itself proves nothing about what a reader gets.
499
+ const modPath = path.join(HALL, 'roadmap-timeline.js');
500
+ const tl = await import(`file://${modPath.replace(/\\/g, '/')}`).then((m) => m.default);
501
+ // public__versions.json, NOT versions.json — the deck calls /public/versions,
502
+ // and the authenticated list is a different payload. Reading the wrong one
503
+ // here is precisely the trap this task's own CLAUDE.md note warns about.
504
+ const fixture = JSON.parse(fs.readFileSync(
505
+ path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'public__versions.json'), 'utf8'));
506
+ const all = fixture.versions.map((v) => ({ id: v.id, start: v.started_at, ship: v.shipped_at }));
507
+ const now = Date.parse('2026-09-03T12:00:00Z');
508
+
509
+ const rows = tl.timelineRows(all, now);
510
+ assert.equal(rows.length, all.length, 'every fixture version has a start date');
511
+ const starts = rows.map((r) => r.from);
512
+ assert.deepEqual(starts, [...starts].sort((a, b) => a - b), 'oldest first');
513
+ assert.ok(rows.some((r) => r.open), 'the in-flight version runs to today');
514
+ assert.ok(rows.every((r) => r.to >= r.from), 'no span runs backwards');
515
+
516
+ const card = tl.timelineCardHtml(all, now, kit);
517
+ assert.equal(card.count, rows.length);
518
+ assert.doesNotMatch(card.html, /NaN|Infinity|undefined/);
519
+ assert.match(card.html, /class="kit-bars"/, 'drawn by the kit, not by hand');
520
+ const offsets = [...card.html.matchAll(/margin-left:(\d+)%/g)].map((m) => Number(m[1]));
521
+ assert.ok(offsets.length >= 2, 'later versions start further along the axis');
522
+ assert.deepEqual(offsets, [...offsets].sort((a, b) => a - b), 'and their offsets grow with their start dates');
523
+ // PMS-V1 started and shipped inside ONE day of a four-month axis: the span
524
+ // that rounds to nothing without the 1% floor. It is also the earliest
525
+ // version, so it correctly carries no offset — assert the floor, not a
526
+ // margin-left the axis origin has no reason to write.
527
+ const pms = card.html.match(/PMS-V1<\/span><span class="kit-bars__track"[^>]*><span class="kit-bars__fill" style="([^"]+)"/);
528
+ assert.ok(pms, 'PMS-V1 has a bar');
529
+ assert.match(pms[1], /width:[1-9]/, 'a one-day span still draws, rather than rounding to nothing');
530
+ assert.doesNotMatch(pms[1], /margin-left/, 'and the earliest version sits at the axis origin');
531
+ });
532
+
533
+ test('RUN: the timeline refuses to draw rather than drawing an empty axis', async () => {
534
+ const modPath = path.join(HALL, 'roadmap-timeline.js');
535
+ const tl = await import(`file://${modPath.replace(/\\/g, '/')}`).then((m) => m.default);
536
+ const now = Date.parse('2026-09-03T12:00:00Z');
537
+ assert.equal(tl.timelineCardHtml([], now, kit), null, 'nothing at all');
538
+ assert.equal(tl.timelineCardHtml([{ id: 'A', start: '2026-01-01' }], now, kit), null,
539
+ 'one version is not a timeline');
540
+ // A version with no start date is left OUT and counted in the foot, never
541
+ // drawn at the axis origin — which would read as "began with the project".
542
+ const card = tl.timelineCardHtml([
543
+ { id: 'A', start: '2026-01-01', ship: '2026-02-01' },
544
+ { id: 'B', start: '2026-03-01', ship: null },
545
+ { id: 'C', start: null, ship: null },
546
+ ], now, kit);
547
+ assert.equal(card.count, 2);
548
+ assert.doesNotMatch(card.html, />C</, 'the unstarted version is not a bar');
549
+ assert.match(card.html, /1 version with no start date is not shown/, 'the foot says so');
550
+ });
@@ -11,6 +11,7 @@ import test from 'node:test';
11
11
  import assert from 'node:assert/strict';
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
+ import vm from 'node:vm';
14
15
  import { fileURLToPath } from 'node:url';
15
16
 
16
17
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -116,3 +117,161 @@ test('the vocabulary wall holds: the family is named oversight on the government
116
117
  assert.ok(!/governance/i.test(read(f)), `${f} does not say the pre-ADR-0174 word`);
117
118
  }
118
119
  });
120
+
121
+ // ---- the repo-health trend (task 1003447, `legibility-series-are-drawn`) ----
122
+ //
123
+ // /public/repo-health has always answered with `latest` PLUS a trend[], and the
124
+ // route's own comment says the trend exists "so the status page can draw LOC /
125
+ // dead-exports / stale-branch lines over time ... oldest->newest so the
126
+ // dashboard can plot left->right". Watch requested it WITH ?days=30 — paying for
127
+ // the whole window — and renderLedger read one snapshot off it. The series
128
+ // arrived over the wire and went on the floor.
129
+ //
130
+ // The AXIS arithmetic is the kit's (plotWindow / timeStepSeriesHtml) and
131
+ // tests/hall_kit_charts.mjs runs it for real. What lives here is Watch's half:
132
+ // that the section composes the kit rather than drawing by hand, and — the part
133
+ // no source assertion can reach — that booting the REAL page against the REAL
134
+ // checked-in fixture puts four finite plots on the panel.
135
+
136
+ const FIXTURES = path.join(ROOT, 'scripts', 'hall-preview', 'fixtures');
137
+ const repoHealthFixture = () => JSON.parse(fs.readFileSync(path.join(FIXTURES, 'public__repo-health.json'), 'utf8'));
138
+
139
+ // Boot watch.js in a stub DOM with the real dom-utils and the real kit, answer
140
+ // /me as an archon and /public/repo-health with the fixture, and hand back what
141
+ // landed in #ledger-trend. Every OTHER endpoint answers 500, which is the
142
+ // page's own design (each section loads independently) and keeps this harness
143
+ // to the one panel it is about.
144
+ async function bootWatchLedger(overrides = {}) {
145
+ const payload = { ...repoHealthFixture(), ...overrides };
146
+ const els = new Map();
147
+ const makeEl = () => ({
148
+ dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
149
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
150
+ setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
151
+ addEventListener() {}, appendChild() {}, insertAdjacentHTML(_, h) { this._html += h; },
152
+ closest() { return null; },
153
+ querySelector() { return null; }, querySelectorAll() { return []; },
154
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
155
+ });
156
+ const documentObj = {
157
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
158
+ querySelector(sel) {
159
+ const s = String(sel);
160
+ return s.startsWith('#') ? this.getElementById(s.slice(1)) : makeEl();
161
+ },
162
+ querySelectorAll() { return []; },
163
+ createElement() { return makeEl(); },
164
+ addEventListener() {},
165
+ };
166
+ const client = {
167
+ request: async (method, urlPath) => {
168
+ if (urlPath.endsWith('/me')) {
169
+ return { ok: true, status: 200, data: { builder: { id: '1', rank: 'archon', github_login: 'x' } } };
170
+ }
171
+ if (urlPath.includes('/public/repo-health')) return { ok: true, status: 200, data: payload };
172
+ return { ok: false, status: 500, data: {} };
173
+ },
174
+ };
175
+ const windowObj = {
176
+ BongosClient: { createClient: () => client },
177
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
178
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
179
+ location: { hash: '', pathname: '/watch', search: '', assign() {} },
180
+ addEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }),
181
+ };
182
+ const sandbox = {
183
+ window: windowObj, document: documentObj, location: windowObj.location,
184
+ localStorage: windowObj.localStorage,
185
+ // The two browser globals the kit reaches for outside `window`: CSS.escape
186
+ // (tab selectors) and history.replaceState (the tab's deep link).
187
+ CSS: { escape: (s) => String(s) },
188
+ history: { replaceState() {} },
189
+ console: { ...console, error() {}, warn() {} },
190
+ setTimeout, clearTimeout, URLSearchParams, Intl,
191
+ };
192
+ sandbox.globalThis = sandbox;
193
+ vm.createContext(sandbox);
194
+ for (const f of ['dom-utils.js', 'hall-kit.js', 'watch.js']) {
195
+ vm.runInContext(read(f), sandbox, { filename: f });
196
+ }
197
+ // load() is async: let every section's promise settle before reading the DOM.
198
+ for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0));
199
+ return { html: documentObj.getElementById('ledger-trend').innerHTML, latest: payload.latest, windowObj };
200
+ }
201
+
202
+ test('the repo-health trend has a mount, and renderLedger fills it', () => {
203
+ const js = read('watch.js');
204
+ assert.match(read('watch.html'), /id="ledger-trend"/, 'the mount exists in the markup');
205
+ assert.match(js, /renderTrend\(data\);/, 'renderLedger hands the whole payload on, not just latest');
206
+ assert.match(js, /'\/public\/repo-health\?days=30'/, 'the request still asks for the window');
207
+ assert.match(js, /data\.window_days/, 'and the heading says which window it drew');
208
+ });
209
+
210
+ test('every mark in the trend is the kit\'s — no hand-drawn plot on the oversight page', () => {
211
+ const js = read('watch.js');
212
+ for (const fn of ['plotWindow', 'timeStepSeriesHtml', 'plotSvgHtml', 'plotValueLabelHtml']) {
213
+ assert.match(js, new RegExp(`kit\\.${fn}\\(`), `the trend draws through ${fn}`);
214
+ }
215
+ // §6b holds this globally; pinned here too because this is the page that
216
+ // hand-rolled a bar chart once already (task 1003445).
217
+ assert.doesNotMatch(js, /<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/,
218
+ 'no hand-built SVG geometry on Watch');
219
+ // No readout is wired, so the plot must drop the focus stop AND the crosshair
220
+ // together — the task 1003446 pairing. Half of it is worse than neither.
221
+ assert.match(js, /interactive: false/, 'the trend plots are not fake-interactive');
222
+ assert.match(js, /class="pchart__twin"/, 'and each carries its table twin instead');
223
+ });
224
+
225
+ test('a fitted axis says so in words, because it magnifies every move', () => {
226
+ // total_loc is the one metric drawn off a non-zero floor: on a zero floor a
227
+ // month of real change is a flat band across the top of the box. That choice
228
+ // makes small movement look large, so the figure states its baseline rather
229
+ // than letting the picture imply a collapse to nothing.
230
+ const js = read('watch.js');
231
+ assert.match(js, /key: 'total_loc',[\s\S]{0,120}zeroBased: false/, 'total_loc fits its floor');
232
+ for (const k of ['dead_exports', 'stale_branches', 'confirmed_unshipped']) {
233
+ assert.match(js, new RegExp(`key: '${k}',[\\s\\S]{0,120}zeroBased: true`), `${k} keeps the zero floor`);
234
+ }
235
+ assert.match(js, /not zero/, 'and a fitted figure names its baseline in the foot');
236
+ });
237
+
238
+ test('RUN: the real page against the real fixture puts four finite plots on the panel', async () => {
239
+ // The half a source assertion cannot see. A NaN in a path `d` renders as an
240
+ // invisible chart with no error anywhere — a grep never finds that, and a
241
+ // stub agreeing with a stub never would either.
242
+ const { html, latest } = await bootWatchLedger();
243
+ assert.ok(html.length > 0, 'the panel drew something');
244
+ assert.doesNotMatch(html, /NaN|Infinity|undefined/, 'no broken geometry reaches the DOM');
245
+ const figures = [...html.matchAll(/id="ledger-trend-([a-z_]+)"/g)].map((m) => m[1]);
246
+ assert.deepEqual(figures, ['total_loc', 'dead_exports', 'stale_branches', 'confirmed_unshipped'],
247
+ 'all four metrics draw, in the reading order');
248
+ assert.equal((html.match(/class="kit-plot"/g) || []).length, 4, 'four plots');
249
+ assert.ok(!/tabindex="0"/.test(html), 'none of them is a focus stop with nothing to do');
250
+ assert.equal((html.match(/class="kit-plot__hair"/g) || []).length, 0, 'and none carries a crosshair nothing moves');
251
+ // The plot and the figure band above it derive from ONE payload, so they
252
+ // cannot disagree: the labelled endpoint is the number the snapshot states.
253
+ const compact = `${(latest.total_loc / 1e3).toFixed(1)}k`;
254
+ assert.ok(html.includes(`>${compact}<`), `the line ends on ${compact}, the value the snapshot figure shows`);
255
+ });
256
+
257
+ test('RUN: a payload with no trend renders nothing rather than an empty panel', async () => {
258
+ // The honest exit the module doc requires. The figures above already say what
259
+ // today's numbers are; an empty card under them would be worse than no card.
260
+ const { html } = await bootWatchLedger({ trend: [] });
261
+ assert.equal(html.trim(), '', 'no heading, no card, no "no data" box');
262
+ });
263
+
264
+ test('RUN: one snapshot is a reading, not a trend', async () => {
265
+ // A single point draws a dot on an axis with nothing to compare it to.
266
+ const { html } = await bootWatchLedger({ trend: repoHealthFixture().trend.slice(-1) });
267
+ assert.equal(html.trim(), '');
268
+ });
269
+
270
+ test('RUN: a metric its snapshots never carried is skipped, not drawn as a cliff', async () => {
271
+ // An older snapshot predates a column and sends null. Reading that as 0 would
272
+ // draw a fall to the floor that never happened, so the figure is dropped.
273
+ const trend = repoHealthFixture().trend.map((r) => ({ ...r, dead_exports: null }));
274
+ const { html } = await bootWatchLedger({ trend });
275
+ const figures = [...html.matchAll(/id="ledger-trend-([a-z_]+)"/g)].map((m) => m[1]);
276
+ assert.deepEqual(figures, ['total_loc', 'stale_branches', 'confirmed_unshipped']);
277
+ });