@bongos/core 1.24.45 → 1.24.47

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 (109) hide show
  1. package/.bongos-core.json +151 -106
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/config/design-tokens.neutral.json +10 -10
  8. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +15 -0
  9. package/docs/api/openapi.json +152 -3
  10. package/docs/api-reference.md +4 -2
  11. package/docs/copy-inventory.md +1102 -1102
  12. package/docs/copy-registry.json +1538 -1538
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/onboarding/diagrams/assertions.json +2 -2
  15. package/docs/page-inventory.json +11 -0
  16. package/docs/page-readings.json +724 -724
  17. package/migrations/core_295_runner_logins.sql +50 -0
  18. package/modules/government/session-scopes.json +5 -0
  19. package/modules/hall-ui/public/ai-studio.states.json +2 -0
  20. package/modules/hall-ui/public/atlas.states.json +2 -0
  21. package/modules/hall-ui/public/board-room.states.json +2 -0
  22. package/modules/hall-ui/public/city.states.json +2 -0
  23. package/modules/hall-ui/public/collab.states.json +2 -0
  24. package/modules/hall-ui/public/commands.states.json +2 -0
  25. package/modules/hall-ui/public/copy-desk.states.json +2 -0
  26. package/modules/hall-ui/public/deploy.states.json +2 -0
  27. package/modules/hall-ui/public/goal-map.states.json +2 -0
  28. package/modules/hall-ui/public/goals.css +1 -0
  29. package/modules/hall-ui/public/goals.states.json +2 -0
  30. package/modules/hall-ui/public/government.states.json +2 -0
  31. package/modules/hall-ui/public/idea.states.json +2 -0
  32. package/modules/hall-ui/public/ideas.states.json +2 -0
  33. package/modules/hall-ui/public/index-founding.states.json +2 -0
  34. package/modules/hall-ui/public/index-genesis-decide.states.json +2 -0
  35. package/modules/hall-ui/public/index-genesis-demo.states.json +2 -0
  36. package/modules/hall-ui/public/index-genesis.states.json +2 -0
  37. package/modules/hall-ui/public/index.states.json +5 -0
  38. package/modules/hall-ui/public/market-watch-item.states.json +2 -0
  39. package/modules/hall-ui/public/market-watch-rules.states.json +2 -0
  40. package/modules/hall-ui/public/market-watch.states.json +2 -0
  41. package/modules/hall-ui/public/modules.states.json +2 -0
  42. package/modules/hall-ui/public/money.states.json +2 -0
  43. package/modules/hall-ui/public/my-agents.states.json +2 -0
  44. package/modules/hall-ui/public/notes-panel.css +1 -1
  45. package/modules/hall-ui/public/primer.states.json +2 -0
  46. package/modules/hall-ui/public/profile.states.json +2 -0
  47. package/modules/hall-ui/public/roster.states.json +2 -0
  48. package/modules/hall-ui/public/settings.states.json +2 -0
  49. package/modules/hall-ui/public/street-card.states.json +2 -0
  50. package/modules/hall-ui/public/street.states.json +2 -0
  51. package/modules/hall-ui/public/studio.states.json +2 -0
  52. package/modules/hall-ui/public/style.css +27 -24
  53. package/modules/hall-ui/public/task.states.json +2 -0
  54. package/modules/hall-ui/public/thinking.states.json +2 -0
  55. package/modules/hall-ui/public/tweak-editor.states.json +2 -0
  56. package/modules/hall-ui/public/watch.states.json +2 -0
  57. package/modules/hall-ui/public/welcome-boot.js +6 -2
  58. package/modules/hall-ui/public/work-founding.states.json +2 -0
  59. package/modules/hall-ui/public/work.states.json +3 -1
  60. package/modules/lifecycle/db-claims.js +6 -3
  61. package/modules/lifecycle/routes/claims.js +10 -0
  62. package/modules/lifecycle/routes/tasks.js +1 -1
  63. package/modules/lifecycle/runner-self-guard.js +19 -10
  64. package/modules/platform-identity/platform-identity.js +1 -1
  65. package/modules/platform-identity/tests/platform-identity.mjs +13 -2
  66. package/modules/provisioning/core-upgrade-authz.js +8 -0
  67. package/modules/public-landing/public/account.html +1 -0
  68. package/modules/public-landing/public/community.html +1 -0
  69. package/modules/public-landing/public/connections.html +1 -0
  70. package/modules/public-landing/public/contact.html +1 -0
  71. package/modules/public-landing/public/guild.html +1 -0
  72. package/modules/public-landing/public/index.html +1 -0
  73. package/modules/public-landing/public/invitations.html +1 -0
  74. package/modules/public-landing/public/privacy.html +1 -0
  75. package/modules/public-landing/public/projects.html +1 -0
  76. package/modules/public-landing/public/terms.html +1 -0
  77. package/modules/raise/views/gate.html +1 -0
  78. package/modules/raise/views/nda.html +1 -0
  79. package/modules/raise/views/page.html +1 -0
  80. package/modules/status-ui/public/index.html +1 -0
  81. package/modules/ui-design/kit/README.md +1 -1
  82. package/modules/ui-design/kit/fixtures/versions.json +1 -0
  83. package/modules/ui-design/kit/lib.js +48 -3
  84. package/modules/ui-design/kit/probe.js +1 -0
  85. package/modules/ui-design/kit/render.js +2 -0
  86. package/modules/ui-design/kit/serve.js +1 -0
  87. package/modules/ui-design/module.json +1 -1
  88. package/modules/ui-design/public/tokens.css +125 -0
  89. package/modules/ui-design/scripts/gen-design-tokens.js +102 -0
  90. package/package-lock.json +2 -2
  91. package/package.json +1 -1
  92. package/release-notes.json +20 -0
  93. package/scripts/bongos/fitness.js +1 -1
  94. package/scripts/bongos/run-unit-tests.js +3 -0
  95. package/src/bongos/auth.js +25 -4
  96. package/src/bongos/route-rank-check.js +5 -0
  97. package/src/bongos/routes/auth.js +48 -0
  98. package/src/bongos/runner-login.js +151 -0
  99. package/src/bongos/serve-internal.js +6 -1
  100. package/src/module-api.js +1 -1
  101. package/tests/autobongos_runner_login.mjs +296 -0
  102. package/tests/autobongos_runner_login_db.mjs +105 -0
  103. package/tests/design_tokens_generated.mjs +56 -0
  104. package/tests/design_tokens_shared.mjs +183 -0
  105. package/tests/government_session_scope.mjs +2 -1
  106. package/tests/hall_reading_world.mjs +5 -1
  107. package/tests/hall_tokens.mjs +22 -3
  108. package/tests/ui_design_kit.mjs +30 -0
  109. package/tests/welcome_boot_browser.mjs +4 -0
@@ -0,0 +1,296 @@
1
+ // tests/autobongos_runner_login.mjs — an Autobongos runner's OWN login (task 1004547,
2
+ // goal 1000139).
3
+ //
4
+ // The done-when this proves: a runner session claiming under a NON-runner worktree
5
+ // name is still recognised as autonomous by the server and refused a self-modifying
6
+ // publish. Plus the owner's extras: the one-time code works once, expires, is
7
+ // Metic+-only to issue; the runner login acts at Metic at most, holds no system
8
+ // permission, and cannot take the owner door to a core move.
9
+ //
10
+ // DB-free: the session lookup, the government port and the pool are stubbed, the
11
+ // way tests/government_session_scope.mjs and tests/government_require_permission.mjs
12
+ // do. The claim route and the publish guard are driven through their real code.
13
+ import assert from 'node:assert/strict';
14
+ import { test } from 'node:test';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+
23
+ // auth.js destructures lookupSession at load, so the stub goes in first.
24
+ const dbKernel = require(path.join(ROOT, 'src', 'bongos', 'db-kernel.js'));
25
+ let nextSession = null;
26
+ dbKernel.lookupSession = async () => nextSession;
27
+
28
+ const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
29
+ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
30
+ const runnerLogin = require(path.join(ROOT, 'src', 'bongos', 'runner-login.js'));
31
+ const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
32
+ const guard = require(path.join(ROOT, 'modules', 'lifecycle', 'runner-self-guard.js'));
33
+ const authz = require(path.join(ROOT, 'modules', 'provisioning', 'core-upgrade-authz.js'));
34
+ const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
35
+
36
+ function fakeRes() {
37
+ const res = {
38
+ _status: null,
39
+ _json: null,
40
+ status(code) { this._status = code; return this; },
41
+ json(body) { this._json = body; return this; },
42
+ };
43
+ attachFail({}, res, () => {});
44
+ return res;
45
+ }
46
+
47
+ async function throughRequireBuilder(session) {
48
+ nextSession = session;
49
+ const req = { headers: { authorization: 'Bearer anytoken' } };
50
+ const res = fakeRes();
51
+ let nexted = false;
52
+ await auth.requireBuilder(req, res, () => { nexted = true; });
53
+ return { req, res, nexted };
54
+ }
55
+
56
+ const ARCHON_RUNNER = { session_id: 91, builder_id: 3, source: 'runner', rank: 'archon', status: 'active', github_login: 'owner' };
57
+ const ARCHON_CLI = { ...ARCHON_RUNNER, session_id: 90, source: 'cli' };
58
+
59
+ // ── the login ────────────────────────────────────────────────────────────────
60
+
61
+ test('a runner login is a declared session source, and requireBuilder admits it', async () => {
62
+ const { nexted, req } = await throughRequireBuilder(ARCHON_RUNNER);
63
+ assert.equal(nexted, true);
64
+ assert.equal(req.builder.runner, true);
65
+ });
66
+
67
+ test('a runner login acts at Metic at most, so every archon rank gate refuses it', async () => {
68
+ const { req } = await throughRequireBuilder(ARCHON_RUNNER);
69
+ assert.equal(req.builder.rank, 'metic', 'an Archon\'s runner is capped');
70
+ const res = fakeRes();
71
+ let nexted = false;
72
+ auth.requireRank('archon')(req, res, () => { nexted = true; });
73
+ assert.equal(nexted, false);
74
+ assert.equal(res._status, 403);
75
+ // The same person's own CLI session keeps their rank: the cap is the login's, not the person's.
76
+ const cli = await throughRequireBuilder(ARCHON_CLI);
77
+ assert.equal(cli.req.builder.rank, 'archon');
78
+ assert.equal(cli.req.builder.runner, false);
79
+ });
80
+
81
+ test('a lower rank is never raised by the cap', () => {
82
+ const R = dbKernel.RANKS;
83
+ assert.equal(runnerLogin.capRunnerRank('xenos', R), 'xenos');
84
+ assert.equal(runnerLogin.capRunnerRank('metic', R), 'metic');
85
+ assert.equal(runnerLogin.capRunnerRank('olympian', R), 'metic');
86
+ assert.equal(runnerLogin.capRunnerRank('nonsense', R), 'nonsense', 'an unknown rank is left for requireRank to fail closed');
87
+ });
88
+
89
+ // ── system permissions ───────────────────────────────────────────────────────
90
+
91
+ async function permissionGate(session, key, held) {
92
+ seams._reset();
93
+ seams.registerProvider('government', {
94
+ resolveBuilderPermissions: async () => held,
95
+ isSystem: catalog.isSystem,
96
+ });
97
+ const req = { builder: { id: 3, rank: 'archon' }, gdsSession: session, rawPermissionKeys: null };
98
+ const res = fakeRes();
99
+ let nexted = false;
100
+ await auth.requirePermission(key)(req, res, () => { nexted = true; });
101
+ seams._reset();
102
+ return { res, nexted };
103
+ }
104
+
105
+ test('a runner login is refused every system permission its builder holds', async () => {
106
+ const systemKeys = catalog.systemKeys().filter((k) => !catalog.isPrincipalKey(k));
107
+ assert.ok(systemKeys.includes('core.pin.move') && systemKeys.includes('autonomy.fence.manage'));
108
+ for (const key of systemKeys) {
109
+ const runner = await permissionGate(ARCHON_RUNNER, key, systemKeys);
110
+ assert.equal(runner.nexted, false, `${key} must be refused to a runner login`);
111
+ assert.equal(runner.res._status, 403);
112
+ const person = await permissionGate(ARCHON_CLI, key, systemKeys);
113
+ assert.equal(person.nexted, true, `${key} stays with the person's own session`);
114
+ }
115
+ });
116
+
117
+ test('a runner login keeps the ordinary permissions its builder holds', async () => {
118
+ const ordinary = catalog.permissionKeys().find((k) => catalog.isSystem(k) === false);
119
+ const r = await permissionGate(ARCHON_RUNNER, ordinary, [ordinary]);
120
+ assert.equal(r.nexted, true);
121
+ });
122
+
123
+ test('with no way to tell a system key, a runner holds nothing', () => {
124
+ assert.equal(runnerLogin.withoutSystemPermissions(new Set(['a', 'b']), undefined).size, 0);
125
+ assert.equal(runnerLogin.withoutSystemPermissions(new Set(['a']), () => null).size, 0, 'an unknown key drops');
126
+ assert.equal(runnerLogin.withoutSystemPermissions(new Set(['a']), () => { throw new Error('x'); }).size, 0);
127
+ });
128
+
129
+ test('a runner login never takes the owner door to a core move', async () => {
130
+ let ownerLookups = 0;
131
+ let pinGateCalled = 0;
132
+ const gate = authz.requireOwnProjectOrPin({
133
+ pinGate: (req, res) => { pinGateCalled++; res.status(403).json({ refused: true }); },
134
+ provisioning: { getInstanceById: async () => { ownerLookups++; return { hosting_shape: 'cloud-host', owner_builder_id: 3 }; } },
135
+ pool: {},
136
+ log: null,
137
+ });
138
+ const res = fakeRes();
139
+ let nexted = false;
140
+ await gate({ params: { id: '41' }, builder: { id: 3, runner: true } }, res, () => { nexted = true; });
141
+ assert.equal(nexted, false);
142
+ assert.equal(pinGateCalled, 1, 'it falls to the permission gate, which a runner fails');
143
+ assert.equal(ownerLookups, 0, 'the ownership door is never asked');
144
+ // The owner's own session still goes through the owner door.
145
+ let ownerNexted = false;
146
+ await gate({ params: { id: '41' }, builder: { id: 3, runner: false } }, fakeRes(), () => { ownerNexted = true; });
147
+ assert.equal(ownerNexted, true);
148
+ });
149
+
150
+ // ── the one-time code ────────────────────────────────────────────────────────
151
+
152
+ // A pool double holding the codes table and the sessions inserted from it.
153
+ function fakePool({ builderStatus = 'active' } = {}) {
154
+ const codes = [];
155
+ const sessions = [];
156
+ const client = {
157
+ async query(sql, params = []) {
158
+ if (/^(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return { rows: [] };
159
+ if (/INSERT INTO runner_enrollment_codes/.test(sql)) {
160
+ const row = { id: codes.length + 1, builder_id: params[0], code_hash: params[1], label: params[2], expires_at: params[3], used_at: null };
161
+ codes.push(row);
162
+ return { rows: [{ id: row.id, expires_at: row.expires_at }] };
163
+ }
164
+ if (/UPDATE runner_enrollment_codes c\s+SET used_at/.test(sql)) {
165
+ const hit = codes.find((c) => c.code_hash === params[0] && !c.used_at && c.expires_at > new Date());
166
+ if (!hit) return { rows: [] };
167
+ hit.used_at = new Date();
168
+ return { rows: [{ id: hit.id, builder_id: hit.builder_id, label: hit.label, github_login: 'owner', status: builderStatus }] };
169
+ }
170
+ if (/INSERT INTO builder_sessions/.test(sql)) {
171
+ sessions.push({ builder_id: params[0], token_hash: params[1], source: params[2], user_agent: params[3] });
172
+ return { rows: [{ id: 500 + sessions.length }] };
173
+ }
174
+ if (/UPDATE runner_enrollment_codes SET used_session_id/.test(sql)) return { rows: [] };
175
+ throw new Error(`unexpected SQL: ${sql}`);
176
+ },
177
+ release() {},
178
+ };
179
+ return { codes, sessions, connect: async () => client, query: client.query };
180
+ }
181
+
182
+ test('a code is issued once, stored only as a hash, and expires in 15 minutes', async () => {
183
+ const pool = fakePool();
184
+ const now = Date.UTC(2026, 10, 6, 12, 0, 0);
185
+ const out = await runnerLogin.issueCode({ builderId: 3, label: 'nucbox\nm8', now }, pool);
186
+ assert.match(out.code, /^abr_[0-9a-f]{64}$/);
187
+ assert.equal(pool.codes.length, 1);
188
+ assert.equal(pool.codes[0].code_hash, dbKernel.sessionTokenHash(out.code));
189
+ assert.ok(!JSON.stringify(pool.codes).includes(out.code), 'the plaintext is never stored');
190
+ assert.equal(pool.codes[0].expires_at.getTime() - now, 15 * 60 * 1000);
191
+ assert.equal(pool.codes[0].label, 'nucbox m8', 'a control character in the label is flattened');
192
+ });
193
+
194
+ test('a code works exactly once and yields a runner session', async () => {
195
+ const pool = fakePool();
196
+ const { code } = await runnerLogin.issueCode({ builderId: 3, label: 'pc' }, pool);
197
+ const first = await runnerLogin.exchangeCode({ code, userAgent: 'setup/1' }, pool);
198
+ assert.equal(first.builder_id, 3);
199
+ assert.match(first.token, /^[0-9a-f]{64}$/);
200
+ assert.equal(pool.sessions.length, 1);
201
+ assert.equal(pool.sessions[0].source, 'runner');
202
+ assert.equal(pool.sessions[0].token_hash, dbKernel.sessionTokenHash(first.token), 'the token is stored only as a hash');
203
+ const again = await runnerLogin.exchangeCode({ code }, pool);
204
+ assert.deepEqual(again, { refused: 'code_invalid' }, 'a used code is refused');
205
+ assert.equal(pool.sessions.length, 1);
206
+ });
207
+
208
+ test('an expired, wrong or malformed code is refused with the same answer', async () => {
209
+ const pool = fakePool();
210
+ const { code } = await runnerLogin.issueCode({ builderId: 3, now: Date.now() - 16 * 60 * 1000 }, pool);
211
+ assert.deepEqual(await runnerLogin.exchangeCode({ code }, pool), { refused: 'code_invalid' });
212
+ assert.deepEqual(await runnerLogin.exchangeCode({ code: 'abr_' + '0'.repeat(64) }, pool), { refused: 'code_invalid' });
213
+ assert.deepEqual(await runnerLogin.exchangeCode({ code: 'not-a-code' }, pool), { refused: 'code_invalid' });
214
+ assert.deepEqual(await runnerLogin.exchangeCode({ code: null }, pool), { refused: 'code_invalid' });
215
+ assert.equal(pool.sessions.length, 0);
216
+ });
217
+
218
+ test('an offboarded builder\'s code mints nothing', async () => {
219
+ const pool = fakePool({ builderStatus: 'inactive' });
220
+ const { code } = await runnerLogin.issueCode({ builderId: 3 }, pool);
221
+ assert.deepEqual(await runnerLogin.exchangeCode({ code }, pool), { refused: 'builder_inactive' });
222
+ assert.equal(pool.sessions.length, 0);
223
+ });
224
+
225
+ // ── the routes ───────────────────────────────────────────────────────────────
226
+
227
+ test('issuing a code is Metic+, cookie-only, and the exchange is a public auth route', () => {
228
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'routes', 'auth.js'), 'utf8');
229
+ assert.match(src, /router\.post\('\/auth\/runner-code\/issue', auth\.requireBuilder, auth\.requireRank\('metic', 'archon'\),/);
230
+ const issue = src.slice(src.indexOf("'/auth/runner-code/issue'"), src.indexOf("'/auth/runner-code/exchange'"));
231
+ assert.match(issue, /if \(!sessionCookieToken\(req\)\)/, 'a bearer (CLI or runner) cannot mint a login');
232
+ const rr = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js')).checkRouteRanks();
233
+ const ranks = Object.fromEntries(rr.routes.map((r) => [r.key, r.rank]));
234
+ assert.equal(ranks['POST /auth/runner-code/issue'], 'metic+archon');
235
+ assert.equal(ranks['POST /auth/runner-code/exchange'], 'public');
236
+ assert.equal(rr.ok, true);
237
+ });
238
+
239
+ // ── the claim and the publish ────────────────────────────────────────────────
240
+
241
+ test('the claim route marks a runner login\'s claim autonomous from the session, never the body', () => {
242
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'claims.js'), 'utf8');
243
+ assert.match(src, /autonomous: !!\(req\.builder && req\.builder\.runner\),/);
244
+ assert.doesNotMatch(src, /autonomous: [^\n]*req\.body/);
245
+ assert.match(src, /if \(req\.builder && req\.builder\.runner\) \{\n\s*return res\.fail\('runner_batch_refused'/);
246
+ const db = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
247
+ assert.match(db, /\$\{autonomous \? ', autonomous' : ''\}/);
248
+ });
249
+
250
+ const files = (...p) => p.map((x) => ({ path: x }));
251
+
252
+ test('DONE WHEN: a runner claim under a person-looking worktree name is still refused a self-modifying publish', async () => {
253
+ const disguised = { worktree_name: 'fix-typo', requested_worktree_name: 'fix-typo', autonomous: true };
254
+ const r = await guard.runnerSelfEditRefusal({ taskId: 4242, prNumber: 77, branch: 'fix-typo' }, {
255
+ readLatestClaim: async () => disguised,
256
+ getPullRequestFiles: async () => files('modules/autonomy/fence.js'),
257
+ });
258
+ assert.equal(r && r.code, 'self_modification');
259
+ assert.deepEqual(r.files, ['modules/autonomy/fence.js']);
260
+ });
261
+
262
+ test('a runner login publishing someone else\'s claim is checked too', async () => {
263
+ const person = { worktree_name: 'mine', requested_worktree_name: 'mine', autonomous: false };
264
+ const r = await guard.runnerSelfEditRefusal({ taskId: 4242, prNumber: 77, publisherIsRunner: true }, {
265
+ readLatestClaim: async () => person,
266
+ getPullRequestFiles: async () => files('scripts/bongos/autobongos-run.js'),
267
+ });
268
+ assert.equal(r && r.code, 'self_modification');
269
+ });
270
+
271
+ test('a person\'s claim published by a person is still never checked', async () => {
272
+ let read = 0;
273
+ const r = await guard.runnerSelfEditRefusal({ taskId: 4242, prNumber: 77, publisherIsRunner: false }, {
274
+ readLatestClaim: async () => ({ worktree_name: 'mine', requested_worktree_name: null, autonomous: false }),
275
+ getPullRequestFiles: async () => { read++; return files('modules/autonomy/fence.js'); },
276
+ });
277
+ assert.equal(r, null);
278
+ assert.equal(read, 0);
279
+ });
280
+
281
+ test('the guard reads the autonomous mark from the claim row, and the route passes the publisher', async () => {
282
+ const seen = [];
283
+ await guard.readLatestClaim(4242, { query: async (sql) => { seen.push(sql); return { rows: [] }; } });
284
+ assert.match(seen[0], /c\.autonomous/);
285
+ const tasks = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'tasks.js'), 'utf8');
286
+ assert.match(tasks, /publisherIsRunner: !!\(req\.builder && req\.builder\.runner\)/);
287
+ });
288
+
289
+ test('the migration adds the source, the codes table and the claim mark, and stores no plaintext', () => {
290
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_295_runner_logins.sql'), 'utf8');
291
+ assert.match(sql, /CHECK \(source IN \('cli', 'web', 'runner'\)\)/);
292
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS runner_enrollment_codes/);
293
+ assert.match(sql, /code_hash\s+text NOT NULL UNIQUE/);
294
+ assert.doesNotMatch(sql, /\bcode\s+text\b/, 'no plaintext code column');
295
+ assert.match(sql, /ADD COLUMN IF NOT EXISTS autonomous boolean NOT NULL DEFAULT false/);
296
+ });
@@ -0,0 +1,105 @@
1
+ // tests/autobongos_runner_login_db.mjs — an Autobongos runner's one-time code against
2
+ // a REAL Postgres (task 1004547, goal 1000139).
3
+ //
4
+ // tests/autobongos_runner_login.mjs proves the login DB-free, but its fake pool
5
+ // re-implements the WHERE that burns a code, so it cannot catch that WHERE being
6
+ // wrong. This file runs the real migration (core_295) and the real exchangeCode SQL:
7
+ // a code works once even when two PCs race it, an expired code mints nothing, and
8
+ // the session it mints carries source 'runner', which the widened CHECK admits.
9
+ //
10
+ // It SELF-SKIPS (exit 0) with no reachable Postgres, and refuses any database that
11
+ // already has a `builders` table — it creates its own throwaway tables. Listed in
12
+ // run-unit-tests.js's INTEGRATION set.
13
+ //
14
+ // Run: createdb runner_login_scratch && DATABASE_URL=postgres:///runner_login_scratch node tests/autobongos_runner_login_db.mjs
15
+
16
+ import pg from 'pg';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import url from 'node:url';
20
+ import { createRequire } from 'node:module';
21
+ import { strict as assert } from 'node:assert';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || undefined });
25
+
26
+ try {
27
+ await pool.query('SELECT 1');
28
+ } catch (e) {
29
+ console.log(`autobongos_runner_login_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
30
+ await pool.end().catch(() => {});
31
+ process.exit(0);
32
+ }
33
+ const { rows: realRows } = await pool.query(`SELECT to_regclass('public.builders') IS NOT NULL AS has_builders`);
34
+ if (realRows[0].has_builders) {
35
+ console.log('autobongos_runner_login_db.mjs SKIPPED — this database has a `builders` table, so it is not a scratch DB.');
36
+ await pool.end().catch(() => {});
37
+ process.exit(0);
38
+ }
39
+
40
+ let passed = 0, failed = 0;
41
+ async function t(name, fn) {
42
+ try { await fn(); passed++; console.log(` ok ${name}`); }
43
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
44
+ }
45
+
46
+ // The minimum of the three core tables the migration alters, as 003_pms.sql made them.
47
+ await pool.query(`
48
+ CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
49
+ CREATE TABLE builders (id bigserial PRIMARY KEY, github_login text, status text NOT NULL DEFAULT 'active');
50
+ CREATE TABLE builder_sessions (
51
+ id bigserial PRIMARY KEY, builder_id bigint REFERENCES builders(id), token_hash text,
52
+ source text NOT NULL CHECK (source IN ('cli', 'web')), user_agent text, expires_at timestamptz);
53
+ CREATE TABLE claims (id bigserial PRIMARY KEY, task_id bigint);
54
+ `);
55
+ const HERE = path.dirname(url.fileURLToPath(import.meta.url));
56
+ await pool.query(fs.readFileSync(path.join(HERE, '..', 'migrations', 'core_295_runner_logins.sql'), 'utf8'));
57
+ // Re-running is a no-op (every step is IF NOT EXISTS / DROP IF EXISTS).
58
+ await pool.query(fs.readFileSync(path.join(HERE, '..', 'migrations', 'core_295_runner_logins.sql'), 'utf8'));
59
+
60
+ const runnerLogin = require('../src/bongos/runner-login.js');
61
+ const { rows: [owner] } = await pool.query(`INSERT INTO builders (github_login) VALUES ('owner') RETURNING id`);
62
+ const { rows: [gone] } = await pool.query(`INSERT INTO builders (github_login, status) VALUES ('gone', 'inactive') RETURNING id`);
63
+
64
+ await t('a code trades for a runner session exactly once', async () => {
65
+ const { code } = await runnerLogin.issueCode({ builderId: owner.id, label: 'pc' }, pool);
66
+ const first = await runnerLogin.exchangeCode({ code }, pool);
67
+ assert.equal(String(first.builder_id), String(owner.id));
68
+ const { rows } = await pool.query('SELECT source, expires_at FROM builder_sessions WHERE id = $1', [first.session_id]);
69
+ assert.equal(rows[0].source, 'runner');
70
+ assert.equal(rows[0].expires_at, null, 'a runner login does not lapse; it is revoked');
71
+ assert.deepEqual(await runnerLogin.exchangeCode({ code }, pool), { refused: 'code_invalid' });
72
+ });
73
+
74
+ await t('two PCs racing one code: one wins, one session', async () => {
75
+ const { code } = await runnerLogin.issueCode({ builderId: owner.id }, pool);
76
+ const before = (await pool.query('SELECT count(*)::int AS n FROM builder_sessions')).rows[0].n;
77
+ const results = await Promise.all([runnerLogin.exchangeCode({ code }, pool), runnerLogin.exchangeCode({ code }, pool)]);
78
+ assert.equal(results.filter((r) => r.token).length, 1);
79
+ assert.equal(results.filter((r) => r.refused === 'code_invalid').length, 1);
80
+ const after = (await pool.query('SELECT count(*)::int AS n FROM builder_sessions')).rows[0].n;
81
+ assert.equal(after - before, 1);
82
+ });
83
+
84
+ await t('an expired code mints nothing', async () => {
85
+ const { code } = await runnerLogin.issueCode({ builderId: owner.id, now: Date.now() - 16 * 60 * 1000 }, pool);
86
+ assert.deepEqual(await runnerLogin.exchangeCode({ code }, pool), { refused: 'code_invalid' });
87
+ const { rows } = await pool.query('SELECT used_at FROM runner_enrollment_codes WHERE code_hash = $1', [require('../src/bongos/db-kernel.js').sessionTokenHash(code)]);
88
+ assert.equal(rows[0].used_at, null, 'an expired code is not even marked used');
89
+ });
90
+
91
+ await t('an offboarded builder\'s code mints nothing and stays unused', async () => {
92
+ const { code } = await runnerLogin.issueCode({ builderId: gone.id }, pool);
93
+ assert.deepEqual(await runnerLogin.exchangeCode({ code }, pool), { refused: 'builder_inactive' });
94
+ const { rows } = await pool.query(`SELECT count(*)::int AS n FROM builder_sessions WHERE builder_id = $1`, [gone.id]);
95
+ assert.equal(rows[0].n, 0);
96
+ });
97
+
98
+ await t('every claim reads not-autonomous by default; the mark is a real column', async () => {
99
+ const { rows } = await pool.query(`INSERT INTO claims (task_id) VALUES (1) RETURNING autonomous`);
100
+ assert.equal(rows[0].autonomous, false);
101
+ });
102
+
103
+ await pool.end();
104
+ console.log(`\nautobongos_runner_login_db.mjs: ${passed} passed, ${failed} failed`);
105
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,56 @@
1
+ // tests/design_tokens_generated.mjs — config/design-tokens.neutral.json is GENERATED
2
+ // from the branding pack and the shared token sheet, and regenerates byte-identical
3
+ // (task 1005082, goal 1000138).
4
+ //
5
+ // WHY. The hand-written file's spacing and motion had drifted from every page's
6
+ // (4/8/16px against the 6/10/14px rhythm). The generator removes the hand; this
7
+ // test is what notices a hand coming back, or a token-sheet edit nobody regenerated.
8
+ //
9
+ // Run: node --test tests/design_tokens_generated.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { test } from 'node:test';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { createRequire } from 'node:module';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const gen = require(path.join(ROOT, 'modules', 'ui-design', 'scripts', 'gen-design-tokens.js'));
21
+
22
+ test('the committed token file is exactly what the generator writes', () => {
23
+ const committed = fs.readFileSync(path.join(ROOT, 'config', 'design-tokens.neutral.json'), 'utf8');
24
+ assert.equal(committed, gen.build(),
25
+ 'config/design-tokens.neutral.json is stale or hand-edited: run node modules/ui-design/scripts/gen-design-tokens.js');
26
+ });
27
+
28
+ test('every scale, radius and motion value is the shared sheet\'s --ds-* value', () => {
29
+ const vars = gen.readSheetVars(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'public', 'tokens.css'), 'utf8'));
30
+ const tokens = JSON.parse(gen.build());
31
+ for (const group of ['scale', 'radius', 'motion']) {
32
+ for (const [key, prop] of Object.entries(gen.MAP[group])) {
33
+ assert.equal(tokens[group][key], vars[prop], `${group}.${key} must be ${prop}`);
34
+ }
35
+ }
36
+ assert.equal(tokens.scale.xs, '6px', 'the rhythm starts at the 6px hair step');
37
+ });
38
+
39
+ test('a token-sheet change reaches the generated file (the generator reads the sheet, not a copy)', () => {
40
+ const sheet = ':root { --ds-space-hair: 7px; --ds-space-xs: 10px; --ds-space-sm: 14px; --ds-space-md: 22px;'
41
+ + ' --ds-space-lg: 34px; --ds-space-xl: 52px; --ds-radius-tile: 10px; --ds-radius-panel: 16px;'
42
+ + ' --ds-radius-pill: 999px; --ds-dur-fast: 150ms; --ds-dur-base: 200ms; --ds-dur-arrive: 700ms;'
43
+ + ' --ds-ease: cubic-bezier(.2, .7, .3, 1); }';
44
+ const pack = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'));
45
+ assert.equal(JSON.parse(gen.build({ sheet, pack })).scale.xs, '7px');
46
+ });
47
+
48
+ test('a --ds-* step the mapping needs but the sheet lost is an error, not a silent gap', () => {
49
+ const pack = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'));
50
+ assert.throws(() => gen.build({ sheet: ':root { --ds-space-hair: 6px; }', pack }), /is not declared/);
51
+ });
52
+
53
+ test('only the base :root block is read: the dark and reduced-motion restatements do not leak in', () => {
54
+ const vars = gen.readSheetVars(':root { --ds-dur-fast: 150ms; } @media (prefers-reduced-motion: reduce) { :root { --ds-dur-fast: 0ms; } }');
55
+ assert.equal(vars['--ds-dur-fast'], '150ms');
56
+ });
@@ -0,0 +1,183 @@
1
+ // tests/design_tokens_shared.mjs — the ONE shared token sheet every Cloud Bongos
2
+ // surface loads (task 1005081, goal 1000138 "One Cloud Bongos design system").
3
+ //
4
+ // modules/ui-design/public/tokens.css holds the named scales — type sizes, spacing,
5
+ // corners, shadows, motion, stacking layers — namespaced --ds-*, and the hall's own
6
+ // scale names became aliases onto it. What this pins, DB-free and browser-free:
7
+ // * the sheet exists and defines every scale;
8
+ // * it never redefines the fifteen pack tokens (the pack must keep winning) and
9
+ // carries no colour literal except black/white with alpha (the Fifteen Rule);
10
+ // * its dark rule is written twice, byte-identical (the dark twins);
11
+ // * every surface loads it — the hall through style.css's @import (all 33 hall
12
+ // pages link style.css), the landing + hub pages, the status page and the three
13
+ // raise views through a <link> placed BEFORE their own sheet, so a surface's own
14
+ // values still win on source order;
15
+ // * the hall's old token names still resolve — each alias names a --ds-* token the
16
+ // sheet defines;
17
+ // * the one URL is served on every host, status. included (serve-internal.js's
18
+ // module-asset route used to step aside on a generic surface host).
19
+ //
20
+ // Run: node --test tests/design_tokens_shared.mjs
21
+
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import fs from 'node:fs';
25
+ import http from 'node:http';
26
+ import path from 'node:path';
27
+ import { createRequire } from 'node:module';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const URL_PATH = '/modules/ui-design/public/tokens.css';
33
+ const SHEET = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'public', 'tokens.css'), 'utf8');
34
+ const decomment = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '');
35
+ const CODE = decomment(SHEET);
36
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
37
+
38
+ const blockAfter = (src, marker) => {
39
+ const at = src.indexOf(marker);
40
+ assert.notEqual(at, -1, `missing ${marker}`);
41
+ const open = src.indexOf('{', at);
42
+ return src.slice(open + 1, src.indexOf('}', open));
43
+ };
44
+ const decls = (body) => Object.fromEntries([...body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));
45
+ const ROOT_TOKENS = decls(blockAfter(CODE, ':root {'));
46
+
47
+ test('the shared sheet defines every named scale', () => {
48
+ const scales = {
49
+ 'type sizes': ['--ds-text-3xs', '--ds-text-2xs', '--ds-text-xs', '--ds-text-sm', '--ds-text-md', '--ds-text-lg', '--ds-text-xl', '--ds-text-2xl', '--ds-text-3xl', '--ds-text-4xl', '--ds-text-5xl', '--ds-text-title', '--ds-text-headline', '--ds-text-figure', '--ds-text-display'],
50
+ spacing: ['--ds-space-hair', '--ds-space-xs', '--ds-space-sm', '--ds-space-md', '--ds-space-lg', '--ds-space-xl'],
51
+ corners: ['--ds-radius-pill', '--ds-radius-panel', '--ds-radius-tile'],
52
+ shadows: ['--ds-shadow-pop'],
53
+ motion: ['--ds-dur-fast', '--ds-dur-base', '--ds-dur-slow', '--ds-ease', '--ds-ease-arrive'],
54
+ 'stacking layers': ['--ds-z-progress', '--ds-z-topbar', '--ds-z-sidebar', '--ds-z-scrim', '--ds-z-drawer', '--ds-z-modal', '--ds-z-diagram-full'],
55
+ };
56
+ for (const [scale, names] of Object.entries(scales)) {
57
+ for (const n of names) assert.ok(ROOT_TOKENS[n], `${scale}: ${n} is defined on :root`);
58
+ }
59
+ // DESIGN.md's values, so loading the sheet moves nothing
60
+ assert.equal(ROOT_TOKENS['--ds-radius-pill'], '999px');
61
+ assert.equal(ROOT_TOKENS['--ds-radius-panel'], '16px');
62
+ assert.equal(ROOT_TOKENS['--ds-radius-tile'], '10px');
63
+ assert.deepEqual(['hair', 'xs', 'sm', 'md', 'lg', 'xl'].map((k) => ROOT_TOKENS[`--ds-space-${k}`]), ['6px', '10px', '14px', '22px', '34px', '52px']);
64
+ assert.equal(ROOT_TOKENS['--ds-ease'].replace(/\s+/g, ''), 'cubic-bezier(.2,.7,.3,1)', 'the one state easing');
65
+ assert.equal(ROOT_TOKENS['--ds-ease-arrive'].replace(/\s+/g, ''), 'cubic-bezier(.16,1,.3,1)', 'the entrance ease-out');
66
+ // the type sizes climb
67
+ const px = Object.entries(ROOT_TOKENS).filter(([k, v]) => k.startsWith('--ds-text-') && /^[\d.]+px$/.test(v)).map(([, v]) => parseFloat(v));
68
+ assert.deepEqual(px, [...px].sort((a, b) => a - b), 'the fixed type steps are declared small to large');
69
+ });
70
+
71
+ test('every name is --ds-* — no shared name can collide with a surface\'s own', () => {
72
+ // cosmos.css spends --line as a COLOUR and raise.css sets --radius to 10px: an
73
+ // un-namespaced shared token would silently change whichever surface lacked it
74
+ const names = [...CODE.matchAll(/(--[\w-]+)\s*:/g)].map((m) => m[1]);
75
+ assert.ok(names.length > 40);
76
+ assert.deepEqual(names.filter((n) => !n.startsWith('--ds-')), []);
77
+ });
78
+
79
+ test('it never redefines the fifteen pack tokens and holds no colour literal but black/white alpha', () => {
80
+ const pack = JSON.parse(read('config', 'branding.neutral.json')).theme.ui;
81
+ const fifteen = [...Object.keys(pack.colors).map((k) => '--' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())), '--font-display', '--font-body'];
82
+ assert.equal(fifteen.length, 15);
83
+ for (const t of fifteen) assert.doesNotMatch(CODE, new RegExp(`${t}\\s*:`), `${t} belongs to the pack`);
84
+ assert.doesNotMatch(CODE, /#[0-9a-f]{3,8}\b/i, 'no hex colour');
85
+ for (const m of CODE.matchAll(/rgba?\(([^)]*)\)/g)) {
86
+ const [r, g, b] = m[1].split(',').map((x) => parseFloat(x));
87
+ assert.ok((r === 0 && g === 0 && b === 0) || (r === 255 && g === 255 && b === 255), `only black/white with alpha: ${m[0]}`);
88
+ }
89
+ assert.doesNotMatch(CODE, /\b(hsla?|oklch|lab|lch)\(/, 'no other colour function');
90
+ });
91
+
92
+ test('the dark rule is written twice, byte-identical', () => {
93
+ const sw = blockAfter(CODE, 'html[data-theme="dark"], :root[data-mode="dark"] {');
94
+ const media = CODE.slice(CODE.indexOf('@media (prefers-color-scheme: dark)'));
95
+ const twin = blockAfter(media, 'html:not([data-theme]):not([data-mode="light"]) {');
96
+ assert.ok(sw.trim().length > 0);
97
+ assert.equal(sw.replace(/\s+/g, ' ').trim(), twin.replace(/\s+/g, ' ').trim());
98
+ });
99
+
100
+ test('the reduced-motion switch zeroes the motion scale', () => {
101
+ const rm = blockAfter(CODE.slice(CODE.indexOf('@media (prefers-reduced-motion: reduce)')), ':root {');
102
+ for (const n of Object.keys(ROOT_TOKENS).filter((k) => k.startsWith('--ds-dur-') || k === '--ds-stagger')) {
103
+ assert.match(rm, new RegExp(`${n}\\s*:\\s*0ms`), `${n} is zeroed under reduced motion`);
104
+ }
105
+ });
106
+
107
+ test('the hall loads it: style.css @imports it FIRST, and every hall page links style.css', () => {
108
+ const style = read('modules', 'hall-ui', 'public', 'style.css');
109
+ const imports = [...decomment(style).matchAll(/@import url\("([^"]+)"\)/g)].map((m) => m[1]);
110
+ assert.equal(imports[0], URL_PATH, 'the shared sheet is the first import, so the hall\'s own rules follow it');
111
+ const dir = path.join(ROOT, 'modules', 'hall-ui', 'public');
112
+ const pages = fs.readdirSync(dir).filter((f) => f.endsWith('.html'));
113
+ assert.ok(pages.length > 20);
114
+ for (const p of pages) assert.match(fs.readFileSync(path.join(dir, p), 'utf8'), /<link rel="stylesheet" href="\/builders\/style\.css/, `${p} links style.css`);
115
+ });
116
+
117
+ test('the landing, hub, status and raise pages link it before their own sheet', () => {
118
+ const tag = `<link rel="stylesheet" href="${URL_PATH}">`;
119
+ const pages = [
120
+ ...fs.readdirSync(path.join(ROOT, 'modules', 'public-landing', 'public')).filter((f) => f.endsWith('.html')).map((f) => ['modules', 'public-landing', 'public', f]),
121
+ ['modules', 'status-ui', 'public', 'index.html'],
122
+ ['modules', 'raise', 'views', 'page.html'], ['modules', 'raise', 'views', 'gate.html'], ['modules', 'raise', 'views', 'nda.html'],
123
+ ];
124
+ for (const p of pages) {
125
+ const html = read(...p);
126
+ const own = html.search(/<link rel="stylesheet" href="[^"]*(world|cosmos|style|raise)\.css/);
127
+ if (own === -1) continue; // a page with no surface sheet of its own (none today, but not this test's business)
128
+ const at = html.indexOf(tag);
129
+ assert.ok(at > -1, `${p.join('/')} links the shared sheet`);
130
+ assert.ok(at < own, `${p.join('/')} links it BEFORE its own sheet, so the surface's values still win`);
131
+ }
132
+ });
133
+
134
+ test('the hall\'s old token names still resolve — each alias names a defined --ds-* token', () => {
135
+ const style = decomment(read('modules', 'hall-ui', 'public', 'style.css'));
136
+ const want = ['--line', '--radius', '--radius-sm', '--radius-pill', '--sp-hair', '--sp-xs', '--sp-sm', '--sp-md', '--sp-lg', '--sp-xl',
137
+ '--shadow-pop', '--z-progress', '--z-topbar', '--z-sidebar', '--z-scrim', '--z-drawer', '--z-modal', '--z-diagram-full', '--label-size'];
138
+ for (const n of want) {
139
+ const m = style.match(new RegExp(`${n}\\s*:\\s*var\\((--ds-[\\w-]+)\\)`));
140
+ assert.ok(m, `${n} is an alias onto the shared sheet`);
141
+ assert.ok(ROOT_TOKENS[m[1]], `${n} → ${m[1]} is defined in tokens.css`);
142
+ }
143
+ // the four-step --space-* scale still rides the --sp-* aliases
144
+ for (const n of ['--space-1', '--space-2', '--space-3', '--space-4']) assert.match(style, new RegExp(`${n}\\s*:\\s*var\\(--sp-`));
145
+ });
146
+
147
+ // ── the one URL, on every host ────────────────────────────────────────────────
148
+ // node:http, not fetch: fetch silently drops a Host override (tests/module_asset_subdomain.mjs).
149
+ function get(port, p, host) {
150
+ return new Promise((resolve, reject) => {
151
+ const req = http.request({ host: '127.0.0.1', port, path: p, headers: { Host: host } }, (res) => {
152
+ let body = '';
153
+ res.setEncoding('utf8');
154
+ res.on('data', (c) => { body += c; });
155
+ res.on('end', () => resolve({ status: res.statusCode, body, type: res.headers['content-type'] || '' }));
156
+ });
157
+ req.on('error', reject);
158
+ req.end();
159
+ });
160
+ }
161
+
162
+ test('the sheet is served at one path on the apex, the hall host and the status host', async () => {
163
+ const express = require('express');
164
+ const { mountInternalSurfaces } = require('../src/bongos/serve-internal.js');
165
+ const app = express();
166
+ mountInternalSurfaces(app);
167
+ const server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)); });
168
+ try {
169
+ const port = server.address().port;
170
+ for (const host of ['example.com', 'builders.example.com', 'status.example.com']) {
171
+ const r = await get(port, URL_PATH, host);
172
+ assert.equal(r.status, 200, `${host} serves ${URL_PATH}`);
173
+ assert.match(r.type, /text\/css/, `${host} serves it as CSS`);
174
+ assert.match(r.body, /--ds-radius-panel:/, `${host} serves the shared sheet's bytes`);
175
+ }
176
+ // the status host still answers its OWN files first
177
+ const own = await get(port, '/style.css', 'status.example.com');
178
+ assert.equal(own.status, 200);
179
+ assert.doesNotMatch(own.body, /--ds-radius-panel:/);
180
+ } finally {
181
+ await new Promise((r) => server.close(r));
182
+ }
183
+ });
@@ -41,7 +41,8 @@ const scopes = require(path.join(GOV, 'session-scopes.js'));
41
41
  const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
42
42
 
43
43
  // Hand-written: the sources this instance accepts.
44
- const EXPECTED_SOURCES = ['cli', 'web', null];
44
+ // 'runner' is an Autobongos PC's own login (task 1004547, src/bongos/runner-login.js).
45
+ const EXPECTED_SOURCES = ['cli', 'web', 'runner', null];
45
46
 
46
47
  function fakeRes() {
47
48
  const res = {