@bongos/core 1.19.695 → 1.19.697
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.
- package/.bongos-core.json +33 -38
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -0
- package/docs/copy-inventory.md +14 -24
- package/docs/copy-registry.json +29 -130
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/catalog.js +10 -2
- package/modules/hall-ui/public/panel.css +3 -3
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/shell.js +4 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +20 -0
- package/src/bongos/auth.js +11 -25
- package/src/bongos/serve-internal.js +13 -11
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +9 -8
- package/tests/government_page_gates.mjs +13 -7
- package/tests/hall_page_gate_map.mjs +87 -14
- package/tests/hall_settings_world.mjs +6 -3
- package/tests/scouting_page_gate.mjs +6 -3
- package/tests/secret_box.mjs +83 -9
- package/tests/unit_runner_serial.mjs +72 -0
- package/modules/hall-ui/public/not-ready.html +0 -53
- package/modules/hall-ui/public/not-ready.states.json +0 -14
|
@@ -92,19 +92,20 @@ test('AUDIT: every async middleware mounted directly on a router owns its reject
|
|
|
92
92
|
// The enumeration, kept as an executable assertion rather than prose so it cannot
|
|
93
93
|
// rot: a NEW bare `async function require*` added later fails this test.
|
|
94
94
|
// requireBuilder — the API gate. Guarded here (BV1.R109-follow-up).
|
|
95
|
-
// requireBuilderPage
|
|
96
|
-
// requireGovernmentPage
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
95
|
+
// requireBuilderPage \
|
|
96
|
+
// requireGovernmentPage \ all FOUR delegate to runPageGate, guarded by
|
|
97
|
+
// requireProjectCuratePage / BV1.R107 (the /people curate gate; the
|
|
98
|
+
// requireBoardVotePage / ADR 0266 /board-room vote gate)
|
|
99
|
+
// Task 1003450 removed two more — requireNonXenosPage and the R113
|
|
100
|
+
// requireGovernmentManagePage — which had both ended up dispatched from no
|
|
101
|
+
// route at all; tests/hall_page_gate_map.mjs now fails if that recurs.
|
|
101
102
|
// Anything else async and router-mounted must be added with its own try/catch.
|
|
102
103
|
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
|
|
103
104
|
const asyncMw = [...src.matchAll(/^async function (require\w+|allow\w+)\s*\(req, res, next\)/gm)].map((m) => m[1]);
|
|
104
|
-
assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', '
|
|
105
|
+
assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireGovernmentPage', 'requireProjectCuratePage'],
|
|
105
106
|
'a new async router middleware appeared — give it a try/catch and add it here');
|
|
106
107
|
// The page gates must actually delegate (that is WHY they are safe).
|
|
107
|
-
for (const name of ['requireBuilderPage', 'requireGovernmentPage', '
|
|
108
|
+
for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireProjectCuratePage', 'requireBoardVotePage']) {
|
|
108
109
|
const fn = src.slice(src.indexOf(`async function ${name}`));
|
|
109
110
|
assert.match(sliceFn(fn), /runPageGate\(/, `${name} must delegate to the guarded runPageGate`);
|
|
110
111
|
}
|
|
@@ -50,14 +50,19 @@ const resolver = require('../modules/government/resolver.js');
|
|
|
50
50
|
const wandering = require('../modules/builder-settings/wandering-prefs.js');
|
|
51
51
|
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
52
52
|
|
|
53
|
-
const GATES = ['requireBuilderPage', '
|
|
53
|
+
const GATES = ['requireBuilderPage', 'requireGovernmentPage'];
|
|
54
54
|
const LIVE_RANKS = ['xenos', 'thetes', 'metic', 'archon'];
|
|
55
55
|
|
|
56
56
|
// ═══ the ORACLE — hand-written, from the rank tests R107 deleted ═════════════
|
|
57
57
|
// requireBuilderPage admitted any session at all → every live rank
|
|
58
|
-
// requireNonXenosPage admitted rank !== 'xenos' → thetes and up
|
|
59
58
|
// requireGovernmentPage admitted rank === 'archon' → archon only …until ADR 0157
|
|
60
59
|
//
|
|
60
|
+
// R107 migrated a THIRD gate, requireNonXenosPage (rank !== 'xenos' → thetes and
|
|
61
|
+
// up, via page.view.builder). Task 1002710 emptied it of routes and task 1003450
|
|
62
|
+
// deleted it; its rows are gone from the tables below rather than kept as an
|
|
63
|
+
// oracle for a function that no longer exists. The parity claim it carried is
|
|
64
|
+
// unaffected — it held right up to the deletion.
|
|
65
|
+
//
|
|
61
66
|
// ADR 0157 (owner decision, 2026-08-05) lowered `page.view.government` archon→metic, so
|
|
62
67
|
// the monitoring pages (/watch, /harbor, /gate) now open to the working rank. That gate
|
|
63
68
|
// was spelled `requireArchonPage` through R107 and ADR 0157 — the name the rows below
|
|
@@ -70,7 +75,6 @@ const LIVE_RANKS = ['xenos', 'thetes', 'metic', 'archon'];
|
|
|
70
75
|
// the gate matches its declared floor rather than merely "didn't change".
|
|
71
76
|
const PRE_R107_AUDIENCE = {
|
|
72
77
|
requireBuilderPage: { xenos: true, thetes: true, metic: true, archon: true },
|
|
73
|
-
requireNonXenosPage: { xenos: false, thetes: true, metic: true, archon: true },
|
|
74
78
|
requireGovernmentPage: { xenos: false, thetes: false, metic: true, archon: true },
|
|
75
79
|
};
|
|
76
80
|
// Where a SIGNED-IN builder who fails the gate is sent. Never the sign-in flow —
|
|
@@ -80,12 +84,10 @@ const PRE_R107_AUDIENCE = {
|
|
|
80
84
|
// instead of silently dumping the builder at the home page.
|
|
81
85
|
const DENIED_TO = {
|
|
82
86
|
requireBuilderPage: '/builders',
|
|
83
|
-
requireNonXenosPage: '/builders/not-ready',
|
|
84
87
|
requireGovernmentPage: '/builders',
|
|
85
88
|
};
|
|
86
89
|
const GATE_PERMISSION = {
|
|
87
90
|
requireBuilderPage: 'page.view.public',
|
|
88
|
-
requireNonXenosPage: 'page.view.builder',
|
|
89
91
|
requireGovernmentPage: 'page.view.government',
|
|
90
92
|
};
|
|
91
93
|
const deniedLoc = (gate, { unavailable = false } = {}) =>
|
|
@@ -93,7 +95,6 @@ const deniedLoc = (gate, { unavailable = false } = {}) =>
|
|
|
93
95
|
// The equivalent rank floor, for the independent cross-check below.
|
|
94
96
|
const GATE_FLOOR = {
|
|
95
97
|
requireBuilderPage: 'xenos',
|
|
96
|
-
requireNonXenosPage: 'thetes',
|
|
97
98
|
// ADR 0157: `page.view.government` floor archon→metic.
|
|
98
99
|
requireGovernmentPage: 'metic',
|
|
99
100
|
};
|
|
@@ -264,9 +265,14 @@ test('B4: the page gates resolve per request — no memoised authority (ADR 0016
|
|
|
264
265
|
test('B5: every permission a page gate names is a real catalog key', async () => {
|
|
265
266
|
// Not an oracle — a sanity check that the gates did not migrate onto a typo,
|
|
266
267
|
// which would silently deny everyone (fail-closed, but a bug all the same).
|
|
267
|
-
for (const key of ['page.view.public', 'page.view.
|
|
268
|
+
for (const key of ['page.view.public', 'page.view.government']) {
|
|
268
269
|
assert.ok(catalog.byKey(key), `${key} must exist in the catalog`);
|
|
269
270
|
}
|
|
271
|
+
// page.view.builder is deliberately NOT in that list any more: task 1003450
|
|
272
|
+
// deleted the only gate that named it. The atom is still seeded and attachable
|
|
273
|
+
// (catalog.js says so), so it is still a real key — it just is not a page
|
|
274
|
+
// gate's permission, and asserting it here would imply otherwise.
|
|
275
|
+
assert.ok(catalog.byKey('page.view.builder'), 'the atom survives its gate');
|
|
270
276
|
});
|
|
271
277
|
|
|
272
278
|
// ═══ PART C — the wandering CEILING (role metadata, not a permission) ════════
|
|
@@ -34,11 +34,13 @@ const SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'ut
|
|
|
34
34
|
//
|
|
35
35
|
// Audiences (the floors live in modules/government/catalog.js; the gate
|
|
36
36
|
// functions in src/bongos/auth.js):
|
|
37
|
-
// requireBuilderPage page.view.public
|
|
37
|
+
// requireBuilderPage page.view.public any signed-in builder (xenos+)
|
|
38
38
|
// requireGovernmentPage page.view.government metic+ (ADR 0157)
|
|
39
39
|
// requireProjectCuratePage project.curate metic+
|
|
40
|
-
// requireGovernmentManagePage government.manage archon
|
|
41
40
|
// requireBoardVotePage board.vote.cast xenos+ (ADR 0266)
|
|
41
|
+
// That list is now CHECKED, not just written: "every page gate is dispatched"
|
|
42
|
+
// below reads the gate table out of auth.js and fails if a gate here guards no
|
|
43
|
+
// URL. Task 1003450 added it after finding two that did not.
|
|
42
44
|
const ORACLE = {
|
|
43
45
|
// Any signed-in builder — no rank floor.
|
|
44
46
|
'/builders/work': 'requireBuilderPage',
|
|
@@ -60,10 +62,11 @@ const ORACLE = {
|
|
|
60
62
|
'/builders/studio': 'requireBuilderPage',
|
|
61
63
|
'/builders/ideas': 'requireBuilderPage',
|
|
62
64
|
'/builders/collab': 'requireBuilderPage',
|
|
63
|
-
// task 1002710: the orientation reading rooms moved DOWN to this tier from
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
65
|
+
// task 1002710: the orientation reading rooms moved DOWN to this tier from a
|
|
66
|
+
// thetes+ gate. They are the onboarding documents, so gating them above xenos
|
|
67
|
+
// turned away the exact reader they were written for. The sign-in requirement
|
|
68
|
+
// is unchanged — only the RANK floor is gone. (Task 1003450 then deleted the
|
|
69
|
+
// gate those two rows left behind; see GONE_GATES below.)
|
|
67
70
|
'/builders/primer': 'requireBuilderPage',
|
|
68
71
|
'/builders/primer.md': 'requireBuilderPage',
|
|
69
72
|
'/builders/diagrams': 'requireBuilderPage',
|
|
@@ -118,7 +121,6 @@ const ORACLE = {
|
|
|
118
121
|
'/builders/ranks': null,
|
|
119
122
|
'/builders/drachmae': null,
|
|
120
123
|
'/builders/profile': null,
|
|
121
|
-
'/builders/not-ready': null,
|
|
122
124
|
|
|
123
125
|
// NOT deliberate — recorded because it is true, and filed as idea 1000907
|
|
124
126
|
// (task 1003448). The detail pages are addressed by a second segment, and
|
|
@@ -137,6 +139,15 @@ const ORACLE = {
|
|
|
137
139
|
// from the map" is indistinguishable from the oversight this file exists to
|
|
138
140
|
// catch. GONE_PAGES below keeps the row honest if the file ever returns.
|
|
139
141
|
'/builders/my-projects': null,
|
|
142
|
+
|
|
143
|
+
// Gone too, and for a reason worth keeping in front of the next reader: this
|
|
144
|
+
// was a bespoke refusal PAGE ("not yet — ship three tasks to graduate"), the
|
|
145
|
+
// deniedTo of the thetes+ gate. Task 1002613 replaced that whole idea with a
|
|
146
|
+
// banner the shell renders from ?denied=<permission> on whatever page the
|
|
147
|
+
// redirect lands on — which names the permission actually missing instead of
|
|
148
|
+
// assuming one tier's remedy. Once task 1002710 emptied the gate, nothing
|
|
149
|
+
// linked here or redirected here; task 1003450 removed page and gate together.
|
|
150
|
+
'/builders/not-ready': null,
|
|
140
151
|
};
|
|
141
152
|
|
|
142
153
|
// ─── reconstruct the URL→gate map from the source ───────────────────────────
|
|
@@ -250,6 +261,8 @@ const GONE_PAGES = {
|
|
|
250
261
|
// redirect. tests/hall_settings_world.mjs pins the deletion itself; this row
|
|
251
262
|
// pins what the deletion means for the gate map.
|
|
252
263
|
'/builders/my-projects': 'task 1003308 — the hub owns "my projects"',
|
|
264
|
+
// Task 1003450 — the shell's ?denied= banner replaced the bespoke refusal page.
|
|
265
|
+
'/builders/not-ready': 'task 1003450 — the shell renders the refusal from ?denied=',
|
|
253
266
|
};
|
|
254
267
|
|
|
255
268
|
test('a row that is null because the page left the hall serves no page', () => {
|
|
@@ -261,12 +274,72 @@ test('a row that is null because the page left the hall serves no page', () => {
|
|
|
261
274
|
}
|
|
262
275
|
});
|
|
263
276
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
277
|
+
// ─── the gate table itself: nothing exported that guards nothing ────────────
|
|
278
|
+
//
|
|
279
|
+
// The previous version of this block asserted, by name, that ONE gate
|
|
280
|
+
// (requireNonXenosPage) guarded no URL — recording the dead code instead of
|
|
281
|
+
// removing it. That reads as coverage and is the opposite: it pinned the defect
|
|
282
|
+
// in place, and it could not see requireGovernmentManagePage, which had gone the
|
|
283
|
+
// same way when R16 widened /government's shell to the metic floor. Both gates
|
|
284
|
+
// are gone (task 1003450). What replaces the row is the general rule, which
|
|
285
|
+
// fails on the NEXT one without anybody having to think of its name.
|
|
286
|
+
//
|
|
287
|
+
// Why it matters beyond tidiness: an exported gate with no route is a loaded
|
|
288
|
+
// gun. It still resolves a permission and still redirects, so wiring it is one
|
|
289
|
+
// line — and the reviewer of that line sees a function that looks maintained.
|
|
290
|
+
// requireNonXenosPage would have re-imposed a rank floor the owner had
|
|
291
|
+
// deliberately removed (task 1002710), and its deniedTo pointed at a page that
|
|
292
|
+
// by then said the wrong thing.
|
|
293
|
+
const AUTH_SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/auth.js'), 'utf8');
|
|
294
|
+
|
|
295
|
+
// The keys of the PAGE_GATES table, read statically — the same posture as the
|
|
296
|
+
// dispatch parse above (importing auth.js pulls the whole app in).
|
|
297
|
+
function readPageGateNames(src) {
|
|
298
|
+
const table = /const PAGE_GATES = Object\.freeze\(\{([\s\S]*?)\n\}\);/.exec(src);
|
|
299
|
+
assert.ok(table, 'PAGE_GATES is still a `const PAGE_GATES = Object.freeze({…});` literal');
|
|
300
|
+
// Entry keys sit at exactly one indent level; `permission:` / `deniedTo:` /
|
|
301
|
+
// `alsoAdmits:` are nested deeper, so the two-space anchor keeps them out.
|
|
302
|
+
return [...table[1].matchAll(/^ {2}(require\w+):/gm)].map((m) => m[1]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
test('every page gate in the table is dispatched by at least one hall URL', () => {
|
|
306
|
+
const declared = readPageGateNames(AUTH_SRC);
|
|
307
|
+
assert.ok(declared.length >= 4, 'the gate table was found — an empty parse must not pass silently');
|
|
269
308
|
const wired = new Set(DISPATCH.map((d) => d.gate));
|
|
270
|
-
|
|
271
|
-
|
|
309
|
+
for (const gate of declared) {
|
|
310
|
+
assert.ok(wired.has(gate),
|
|
311
|
+
`${gate} is declared in PAGE_GATES and dispatched from no route. Either wire it to a URL `
|
|
312
|
+
+ '(and add that URL to the ORACLE above), or delete the gate — an exported gate that '
|
|
313
|
+
+ 'guards nothing is a rank floor waiting to be re-imposed by accident.');
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('every gate a URL dispatches is declared in the gate table', () => {
|
|
318
|
+
// The other direction: gdsAuth.<name> in serve-internal.js is resolved at
|
|
319
|
+
// runtime, so a typo or a rename is an undefined-is-not-a-function on the
|
|
320
|
+
// first request to that page rather than a boot failure.
|
|
321
|
+
const declared = new Set(readPageGateNames(AUTH_SRC));
|
|
322
|
+
for (const { gate } of DISPATCH) {
|
|
323
|
+
assert.ok(declared.has(gate), `serve-internal.js dispatches gdsAuth.${gate}, which PAGE_GATES does not declare`);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("every gate's deniedTo is a hall page that is actually served", () => {
|
|
328
|
+
// The failure this catches is the one task 1003450 cleaned up, in its most
|
|
329
|
+
// dangerous form: deleting a page that a live gate still redirects to. The
|
|
330
|
+
// refused visitor would land on a 404 with no way back and no explanation —
|
|
331
|
+
// strictly worse than the 403 the redirect exists to avoid.
|
|
332
|
+
const dests = [...AUTH_SRC.matchAll(/deniedTo:\s*'([^']+)'/g)].map((m) => m[1]);
|
|
333
|
+
assert.ok(dests.length, 'the deniedTo destinations were found');
|
|
334
|
+
for (const dest of dests) {
|
|
335
|
+
assert.ok(dest.startsWith('/builders'), `${dest} must be a hall URL`);
|
|
336
|
+
const slug = dest.replace(/^\/builders\/?/, '');
|
|
337
|
+
// The oracle spells the hall index with its trailing slash; every other row
|
|
338
|
+
// is written without one.
|
|
339
|
+
assert.equal(ORACLE[slug ? dest : '/builders/'], null,
|
|
340
|
+
`${dest} is a deniedTo, so it must be UNGATED — redirecting a refused visitor at a gated page loops`);
|
|
341
|
+
if (!slug) continue; // /builders itself is the hall index, served from index.html
|
|
342
|
+
assert.ok(fs.existsSync(path.join(HALL_PAGES_DIR, `${slug}.html`)),
|
|
343
|
+
`${dest} is a deniedTo destination but no page is served there — a refused visitor gets a 404`);
|
|
344
|
+
}
|
|
272
345
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// tests/hall_settings_world.mjs — the PANEL family in the chrome world
|
|
2
|
-
// (task 1003308, goal 1000081): settings, profile, pair
|
|
2
|
+
// (task 1003308, goal 1000081): settings, profile, pair.
|
|
3
3
|
//
|
|
4
4
|
// tests/hall_tokens.mjs pins the TIER these pages spend; this pins how they
|
|
5
5
|
// spend it. Everything here is a fact that is silent in a browser: the page
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
// node scripts/hall-preview/server.js --port 4621 --fixture-me
|
|
34
34
|
// node modules/ui-design/kit/render.js --states modules/hall-ui/public/settings.states.json \
|
|
35
35
|
// --base-in http://builders.localhost:4621 --base-out http://builders.localhost:4621
|
|
36
|
-
// (and profile / pair
|
|
36
|
+
// (and profile / pair .states.json the same way)
|
|
37
37
|
|
|
38
38
|
import { test } from 'node:test';
|
|
39
39
|
import assert from 'node:assert/strict';
|
|
@@ -46,7 +46,10 @@ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
|
46
46
|
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
47
47
|
const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// not-ready was the fourth until task 1003450 deleted the page — see
|
|
50
|
+
// tests/hall_page_gate_map.mjs (GONE_PAGES) for why, and for the row that stops
|
|
51
|
+
// it coming back ungated. The archetype is unaffected: three pages still spend it.
|
|
52
|
+
const PAGES = ['settings', 'profile', 'pair'];
|
|
50
53
|
const SHEETS = ['panel.css', 'settings.css', 'pair.css', 'profile.css'];
|
|
51
54
|
|
|
52
55
|
const panelCss = read('panel.css');
|
|
@@ -166,9 +166,12 @@ await t('the OTHER page gates are untouched — their seam is never consulted at
|
|
|
166
166
|
'and its denial is unchanged, down to the permission it names');
|
|
167
167
|
const { nexted: pub } = await runGate('requireBuilderPage', OWNER, spy);
|
|
168
168
|
assert.ok(pub, 'the public hall page still admits every signed-in builder, exactly as before');
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
// A third gate stood here — requireNonXenosPage, refusing a xenos to
|
|
170
|
+
// /builders/not-ready. Task 1003450 deleted it and that page together; the two
|
|
171
|
+
// above carry the same claim (a gate declaring no alsoAdmits never touches the
|
|
172
|
+
// seam), and the board-room gate below is the wider third case.
|
|
173
|
+
const { nexted: vote } = await runGate('requireBoardVotePage', OWNER, spy);
|
|
174
|
+
assert.ok(vote, 'board.vote.cast floors at xenos on purpose (ADR 0266) — admitted without the seam');
|
|
172
175
|
});
|
|
173
176
|
|
|
174
177
|
await t('only ONE gate declares a second door (a widening must be opted into, one gate at a time)', () => {
|
package/tests/secret_box.mjs
CHANGED
|
@@ -6,16 +6,20 @@
|
|
|
6
6
|
// tamper detection (a flipped byte fails the auth tag), wrong-key failure, and
|
|
7
7
|
// the fail-closed isConfigured() behaviour.
|
|
8
8
|
//
|
|
9
|
-
// Hermetic: forces BUILDER_SECRET_KEY
|
|
10
|
-
//
|
|
9
|
+
// Hermetic: forces BUILDER_SECRET_KEY and redirects the HOME DIRECTORY — every
|
|
10
|
+
// variable a platform might read for it, not HOME alone — so the
|
|
11
|
+
// ~/.config/<instance>/builder-secret.key fallback cannot resolve to a real
|
|
12
|
+
// operator file on the test machine. withKey then PROVES the redirect landed
|
|
13
|
+
// (os.homedir() must be inside the sandbox) before running any body, and refuses
|
|
14
|
+
// loudly if it did not. HOME alone was a no-op on Windows; see withKey (task 1001435).
|
|
11
15
|
//
|
|
12
16
|
// Run: node tests/secret_box.mjs
|
|
13
17
|
|
|
14
18
|
import { strict as assert } from 'node:assert';
|
|
15
19
|
import { createRequire } from 'node:module';
|
|
16
20
|
import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
-
import { tmpdir } from 'node:os';
|
|
18
|
-
import { join } from 'node:path';
|
|
21
|
+
import os, { tmpdir } from 'node:os';
|
|
22
|
+
import path, { join } from 'node:path';
|
|
19
23
|
import crypto from 'node:crypto';
|
|
20
24
|
|
|
21
25
|
const require = createRequire(import.meta.url);
|
|
@@ -38,18 +42,69 @@ function t(name, fn) {
|
|
|
38
42
|
const KEY_HEX = crypto.randomBytes(32).toString('hex');
|
|
39
43
|
const OTHER_HEX = crypto.randomBytes(32).toString('hex');
|
|
40
44
|
|
|
41
|
-
// Run a thunk with BUILDER_SECRET_KEY + an empty
|
|
45
|
+
// Run a thunk with BUILDER_SECRET_KEY + an empty home, restoring everything after.
|
|
46
|
+
//
|
|
47
|
+
// SETTING HOME ALONE IS NOT ISOLATION (task 1001435, applying task 1003760's
|
|
48
|
+
// contract). This file's header promises the real ~/.config/<instance>/
|
|
49
|
+
// builder-secret.key "is never touched", and secret-box.js finds that file via
|
|
50
|
+
// instance-config configHome() -> os.homedir(). On Windows os.homedir() reads
|
|
51
|
+
// USERPROFILE and NEVER consults HOME, so the override below isolated nothing
|
|
52
|
+
// there: the promise held on POSIX and was silently false on every Windows
|
|
53
|
+
// checkout. Exactly the miss that cost real credentials in task 1003760, which
|
|
54
|
+
// found ten files using this pattern and left the stragglers as task 1003763.
|
|
55
|
+
//
|
|
56
|
+
// No key file was in fact being written to the real config dir — measured before
|
|
57
|
+
// changing this, by running the suite and checking the path — because every
|
|
58
|
+
// provisioning case passes an explicit `file`. So this closes a LATENT hole, not
|
|
59
|
+
// an active leak. It is still worth closing: the next case added here would
|
|
60
|
+
// reasonably trust the header's promise, and the failure mode is destroying a
|
|
61
|
+
// builder's own master key.
|
|
62
|
+
// `home` is always an mkdtempSync(os.tmpdir()) path we just created, so the
|
|
63
|
+
// HOMEDRIVE/HOMEPATH split below is plain string work on a value of our own making
|
|
64
|
+
// — never attacker-influenced. If this shape is ever reused on an untrusted path,
|
|
65
|
+
// split with path.win32 helpers instead of slicing.
|
|
66
|
+
function sandboxHomeEnv(home) {
|
|
67
|
+
const { root } = path.parse(home);
|
|
68
|
+
return {
|
|
69
|
+
HOME: home,
|
|
70
|
+
USERPROFILE: home, // Windows: what os.homedir() actually reads
|
|
71
|
+
HOMEDRIVE: root.replace(/[\\/]+$/, ''), // legacy Windows fallback pair
|
|
72
|
+
HOMEPATH: home.slice(root.length - 1) || '\\',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
42
76
|
function withKey(keyValue, fn) {
|
|
43
77
|
const origKey = process.env.BUILDER_SECRET_KEY;
|
|
44
|
-
const origHome = process.env.HOME;
|
|
45
78
|
if (keyValue === null) delete process.env.BUILDER_SECRET_KEY;
|
|
46
79
|
else process.env.BUILDER_SECRET_KEY = keyValue;
|
|
47
|
-
|
|
48
|
-
|
|
80
|
+
|
|
81
|
+
const home = mkdtempSync(join(tmpdir(), 'otb-secretbox-'));
|
|
82
|
+
const sandbox = sandboxHomeEnv(home);
|
|
83
|
+
const orig = {};
|
|
84
|
+
for (const k of Object.keys(sandbox)) orig[k] = process.env[k];
|
|
85
|
+
Object.assign(process.env, sandbox);
|
|
86
|
+
try {
|
|
87
|
+
// Layer 2 of the same contract: PROVE the override landed before running a
|
|
88
|
+
// body that can write a master key. os.homedir() re-reads the environment on
|
|
89
|
+
// every call (measured on win32: it follows USERPROFILE mid-process), so this
|
|
90
|
+
// is a real check, not a tautology. A future platform that reads some third
|
|
91
|
+
// variable fails here LOUDLY instead of quietly writing to the real config dir
|
|
92
|
+
// — the only acceptable outcome for a test whose subject is a secret file.
|
|
93
|
+
if (os.homedir() !== home) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`secret_box sandbox escape: os.homedir() is ${os.homedir()} but the sandbox is ${home} — `
|
|
96
|
+
+ 'refusing to run, this would touch the real builder-secret.key.'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
fn();
|
|
100
|
+
}
|
|
49
101
|
finally {
|
|
50
102
|
if (origKey === undefined) delete process.env.BUILDER_SECRET_KEY;
|
|
51
103
|
else process.env.BUILDER_SECRET_KEY = origKey;
|
|
52
|
-
|
|
104
|
+
for (const [k, v] of Object.entries(orig)) {
|
|
105
|
+
if (v === undefined) delete process.env[k];
|
|
106
|
+
else process.env[k] = v;
|
|
107
|
+
}
|
|
53
108
|
}
|
|
54
109
|
}
|
|
55
110
|
|
|
@@ -146,6 +201,25 @@ t('decrypt rejects a malformed blob', () => {
|
|
|
146
201
|
});
|
|
147
202
|
});
|
|
148
203
|
|
|
204
|
+
// task 1001435: the isolation this file's header PROMISES, asserted instead of
|
|
205
|
+
// assumed. Before this, withKey set HOME only — which on Windows os.homedir()
|
|
206
|
+
// never reads — so the promise that the real builder-secret.key "is never
|
|
207
|
+
// touched" was true on POSIX and false here, and nothing would have said so.
|
|
208
|
+
t('withKey actually isolates the home dir — the header\'s promise, checked', () => {
|
|
209
|
+
const realHome = os.homedir();
|
|
210
|
+
let insideHome = null;
|
|
211
|
+
let insideConfig = null;
|
|
212
|
+
withKey(KEY_HEX, () => {
|
|
213
|
+
insideHome = os.homedir();
|
|
214
|
+
// The path secret-box.js would resolve for the key file, via the same
|
|
215
|
+
// instance-config chain it uses at runtime.
|
|
216
|
+
insideConfig = join(os.homedir(), '.config');
|
|
217
|
+
});
|
|
218
|
+
assert.notEqual(insideHome, realHome, 'the body must not see the builder\'s real home');
|
|
219
|
+
assert.ok(insideConfig.startsWith(insideHome), 'and the config dir must sit inside the sandbox');
|
|
220
|
+
assert.equal(os.homedir(), realHome, 'and the real home is restored afterwards');
|
|
221
|
+
});
|
|
222
|
+
|
|
149
223
|
console.log('\nprovisionMasterKey (task 1398 — productized, idempotent, never-overwrite):');
|
|
150
224
|
|
|
151
225
|
// Split in two (task 1003529): the creation claim holds on every platform, the MODE
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// tests/unit_runner_serial.mjs
|
|
2
|
+
//
|
|
3
|
+
// The `unit` gate runs its tests SERIALLY, one child process each — pinned here
|
|
4
|
+
// because that property is now load-bearing, not incidental (task 1001435).
|
|
5
|
+
//
|
|
6
|
+
// A flaky-`unit` report (idea #353) diagnosed "parallel execution racing on shared
|
|
7
|
+
// global state" and asked, correctly, whether run-unit-tests fans tests out
|
|
8
|
+
// concurrently in one process. It does not: runOne calls execFileSync, which
|
|
9
|
+
// BLOCKS, from plain sequential for-loops. That rules an in-process race out by
|
|
10
|
+
// construction, and scripts/gds/run-unit-tests.js now records the audit that
|
|
11
|
+
// followed from it — including that two of the three suites the report named
|
|
12
|
+
// (fitness, session_index) mutate no global state at all.
|
|
13
|
+
//
|
|
14
|
+
// All of that reasoning is only true while execution stays serial. Make the runner
|
|
15
|
+
// concurrent and every word of it silently becomes wrong, with no test to say so —
|
|
16
|
+
// so this suite fails if that happens, and whoever does it has to revisit the note
|
|
17
|
+
// rather than leave a stale explanation behind.
|
|
18
|
+
//
|
|
19
|
+
// Static source assertions on purpose: actually observing concurrency would mean
|
|
20
|
+
// running the whole gate from inside the gate.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/unit_runner_serial.mjs
|
|
23
|
+
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const RUNNER = path.join(ROOT, 'scripts', 'gds', 'run-unit-tests.js');
|
|
31
|
+
const src = fs.readFileSync(RUNNER, 'utf8');
|
|
32
|
+
|
|
33
|
+
let passed = 0;
|
|
34
|
+
let failed = 0;
|
|
35
|
+
function t(name, fn) {
|
|
36
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
37
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
t('each test runs in its OWN process, spawned synchronously', () => {
|
|
41
|
+
assert.match(src, /execFileSync\(\s*process\.execPath/,
|
|
42
|
+
'the runner must spawn each test with execFileSync (blocking, one process per test)');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
t('the run loops are sequential — no concurrency primitive in the runner', () => {
|
|
46
|
+
// Promise.all / allSettled over the file list, a worker pool, or the async
|
|
47
|
+
// execFile would each make the suites overlap and reintroduce the shared-state
|
|
48
|
+
// class the recorded audit rules out.
|
|
49
|
+
assert.doesNotMatch(src, /Promise\.(all|allSettled|race)\s*\(/,
|
|
50
|
+
'a Promise.all over the test list would run suites concurrently');
|
|
51
|
+
assert.doesNotMatch(src, /require\(['"]node:worker_threads['"]\)|from ['"]node:worker_threads['"]/,
|
|
52
|
+
'a worker pool would run suites concurrently');
|
|
53
|
+
assert.doesNotMatch(src, /\bexecFile\s*\(/,
|
|
54
|
+
'the ASYNC execFile would overlap suites; the runner uses execFileSync');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
t('the loop over the discovered test files is a plain for-of', () => {
|
|
58
|
+
assert.match(src, /for \(const name of files\) runOne\(/,
|
|
59
|
+
'sequential iteration is the mechanism — not a map() returning promises');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
t('the serial guarantee is EXPLAINED where it is relied on, not just true by accident', () => {
|
|
63
|
+
// The audit recorded beside the loop is what a future flake investigation reads
|
|
64
|
+
// instead of re-deriving it. If the loop is rewritten the note must be revisited,
|
|
65
|
+
// which is the whole point of pinning it next to the mechanism.
|
|
66
|
+
assert.match(src, /EXECUTION IS SERIAL, ONE PROCESS PER TEST/,
|
|
67
|
+
'run-unit-tests.js must carry the note explaining why serialism is relied upon');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
console.log('');
|
|
71
|
+
console.log(`unit_runner_serial: ${passed} passed, ${failed} failed`);
|
|
72
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>Not yet available — {{worldName}}</title>
|
|
7
|
-
<meta name="description" content="This page unlocks as you advance. Ship your first three tasks to open the rest of the builder hub.">
|
|
8
|
-
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
|
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
-
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
12
|
-
<!-- The PANEL archetype (task 1003308): this page is its sign-in gate shape. -->
|
|
13
|
-
<link rel="stylesheet" href="/builders/panel.css">
|
|
14
|
-
</head>
|
|
15
|
-
<body data-page="not-ready" data-page-title="Locked">
|
|
16
|
-
|
|
17
|
-
<div class="app">
|
|
18
|
-
<aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
|
|
19
|
-
<div class="app-scrim" id="app-scrim" hidden></div>
|
|
20
|
-
<div class="app-body">
|
|
21
|
-
<header class="app-topbar" id="app-topbar"></header>
|
|
22
|
-
<div class="app-content">
|
|
23
|
-
<!-- The page's ONE h1 (one-header-main-h1), in the family's voice. -->
|
|
24
|
-
<div class="page-head page-head--room page-head--panel">
|
|
25
|
-
<h1 id="not-ready-h1" class="page-head__h">not yet</h1>
|
|
26
|
-
<p class="page-head__sub">This page unlocks as you advance.</p>
|
|
27
|
-
</div>
|
|
28
|
-
<main id="hall-main">
|
|
29
|
-
<section class="scroll gate" aria-labelledby="not-ready-h">
|
|
30
|
-
<h2 id="not-ready-h" class="gate__h">Not yet available</h2>
|
|
31
|
-
<p class="gate__lede">
|
|
32
|
-
Xenos builders start with a focused set of pages — ship your first
|
|
33
|
-
<strong>three tasks</strong> to graduate and open the rest of
|
|
34
|
-
{{worldName}}'s builder hub.
|
|
35
|
-
</p>
|
|
36
|
-
<div class="row-end">
|
|
37
|
-
<a class="pbtn pbtn--save" href="/ranks">See how ranks work</a>
|
|
38
|
-
<a class="pbtn pbtn--quiet" href="/">Back to home</a>
|
|
39
|
-
</div>
|
|
40
|
-
</section>
|
|
41
|
-
</main>
|
|
42
|
-
<footer class="app-foot" id="app-foot"></footer>
|
|
43
|
-
</div>
|
|
44
|
-
</div>
|
|
45
|
-
</div>
|
|
46
|
-
|
|
47
|
-
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
48
|
-
|
|
49
|
-
<script src="/builders/shell.js"></script>
|
|
50
|
-
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
51
|
-
<script src="/builders/palette.js" defer></script>
|
|
52
|
-
</body>
|
|
53
|
-
</html>
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"_": "The NOT-READY page through modules/ui-design/kit/render.js (task 1003308) — the sign-in gate shape of the panel family, static markup with no feed. Renders under the kit's own stub and under the harness alike.",
|
|
3
|
-
"page": "/not-ready",
|
|
4
|
-
"surface": "hall-ui",
|
|
5
|
-
"stub": { "prefix": "/builders/" },
|
|
6
|
-
"modeQuery": false,
|
|
7
|
-
"ignoreRequests": ["/api/(gds|bongos)/"],
|
|
8
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
|
|
9
|
-
"states": {
|
|
10
|
-
"locked": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#not-ready-h1", "#not-ready-h"] } },
|
|
11
|
-
"out": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#not-ready-h1"] } }
|
|
12
|
-
},
|
|
13
|
-
"reducedMotion": { "auth": true, "actions": [["wait", 400]] }
|
|
14
|
-
}
|