@bongos/core 1.19.654 → 1.19.656
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 +54 -49
- package/.env.local.example +3 -3
- package/config/branding.neutral.json +1 -1
- package/docker-compose.yml +1 -1
- package/docs/branding-contract.md +1 -1
- package/docs/copy-inventory.md +26 -26
- package/docs/copy-registry.json +32 -32
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/self-host.md +3 -3
- package/docs/recipes/standalone-live-docs.md +1 -1
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/dev-box/routes/box.js +4 -2
- package/modules/hall-ui/public/watch.js +38 -14
- package/modules/lifecycle/db-rank-authz.js +30 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box.js +3 -3
- package/scripts/gds/claude-materialize.js +1 -1
- package/scripts/gds/init.js +2 -2
- package/scripts/gds/module.js +3 -2
- package/scripts/gds/oauth-secret.js +6 -3
- package/scripts/gds/provision-units.js +3 -2
- package/scripts/gds/seed-bongos-coreB-tranche1-tasks.js +1 -1
- package/scripts/gds/seed-bongos-coreB-tranche2-tasks.js +1 -1
- package/scripts/gds/seed-provisioning-tasks.js +1 -1
- package/scripts/gds/status.js +3 -2
- package/scripts/gds/upgrade.js +3 -3
- package/scripts/hall-preview/server.js +1 -1
- package/src/bongos/routes/auth.js +3 -2
- package/src/bongos/routes/backup.js +8 -4
- package/src/bongos/routes/security.js +2 -1
- package/src/bongos/serve-internal.js +2 -2
- package/src/branding.js +24 -7
- package/src/instance-config.js +74 -13
- package/src/module-api.js +1 -1
- package/src/modules.js +15 -7
- package/tests/claude_materialize.mjs +1 -1
- package/tests/cloud_bongos_pack.mjs +1 -1
- package/tests/government_claim_eligibility.mjs +82 -0
- package/tests/init.mjs +1 -1
- package/tests/instance_config.mjs +98 -2
- package/tests/upgrade.mjs +3 -3
- package/tests/watch_applications_queue.mjs +13 -1
- package/tests/watch_sealed_floor.mjs +241 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// tests/watch_sealed_floor.mjs — a sealed panel names the floor the SERVER
|
|
2
|
+
// enforced, EXECUTED (task 1003449).
|
|
3
|
+
//
|
|
4
|
+
// THE DEFECT. Every panel on /watch answered a 403 with the literal string
|
|
5
|
+
// "Archons only." ADR 0157 moved most of this surface to metic — five of the
|
|
6
|
+
// atoms behind these panels carry "(ADR 0157, was archon)" in their own catalog
|
|
7
|
+
// entry — so the page was teaching a permission model the server had stopped
|
|
8
|
+
// implementing. The hall audit filed it against watch.html:30 and sessions.html:27;
|
|
9
|
+
// both of those lines are gone (the page heads were rewritten by task 1003307) and
|
|
10
|
+
// scouting.js was deleted by task 1003440, so the copy actually lived in the
|
|
11
|
+
// fourteen `sealedMsg` strings in watch.js. The reported symptom was real; the
|
|
12
|
+
// reported location was not.
|
|
13
|
+
//
|
|
14
|
+
// WHY THE FIX IS NOT "CHANGE archon TO metic". The floors are MIXED, and a blanket
|
|
15
|
+
// rewrite would have been wrong in the other direction:
|
|
16
|
+
//
|
|
17
|
+
// access_request.review → archon ("Archons only" was CORRECT here)
|
|
18
|
+
// override_request.decide → metic (ADR 0157, was archon)
|
|
19
|
+
// builder.roster.read → metic (ADR 0157, was archon)
|
|
20
|
+
// session.search → metic (ADR 0157, was archon)
|
|
21
|
+
// audit_log.read → metic (ADR 0157, was archon)
|
|
22
|
+
// box.fleet.manage → metic (ADR 0157, was archon)
|
|
23
|
+
//
|
|
24
|
+
// A hardcoded 'metic' is the same defect one ADR later, which is why the fix reads
|
|
25
|
+
// the floor off the DENIAL: requirePermission already rides `details.floors` on
|
|
26
|
+
// every 403, and its own comment in src/bongos/auth.js names hardcoded prose as
|
|
27
|
+
// "the D-16/N-3 failure shape ADR 0157 left behind". The page just never read it.
|
|
28
|
+
//
|
|
29
|
+
// These run the real watch.js in a vm against the real watch.html — the harness
|
|
30
|
+
// tests/watch_applications_queue.mjs established — because the claim under test is
|
|
31
|
+
// what a denied builder READS. A source grep can see that the string changed; it
|
|
32
|
+
// cannot see which sentence a 403 actually paints.
|
|
33
|
+
//
|
|
34
|
+
// Run: node tests/watch_sealed_floor.mjs
|
|
35
|
+
|
|
36
|
+
import { strict as assert } from 'node:assert';
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import vm from 'node:vm';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { makeRunner, makeEl, parseInto } from './helpers.mjs';
|
|
42
|
+
|
|
43
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
44
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
45
|
+
const SRC = fs.readFileSync(path.join(HALL, 'watch.js'), 'utf8');
|
|
46
|
+
const PAGE = fs.readFileSync(path.join(HALL, 'watch.html'), 'utf8');
|
|
47
|
+
|
|
48
|
+
const { test, summary } = makeRunner();
|
|
49
|
+
|
|
50
|
+
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
|
|
51
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
52
|
+
|
|
53
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
54
|
+
async function settle() { for (let i = 0; i < 40; i++) await tick(); }
|
|
55
|
+
|
|
56
|
+
const ok = (data) => ({ status: 200, ok: true, data });
|
|
57
|
+
|
|
58
|
+
// The ADR 0116 envelope requirePermission actually sends: the sentence, plus
|
|
59
|
+
// `details.floors` mapping each MISSING permission key to its catalog floor.
|
|
60
|
+
const denied = (floors) => ({
|
|
61
|
+
status: 403,
|
|
62
|
+
ok: false,
|
|
63
|
+
data: {
|
|
64
|
+
error: {
|
|
65
|
+
code: 'permission_forbidden',
|
|
66
|
+
message: 'missing permission',
|
|
67
|
+
details: { required: Object.keys(floors || {}), held: [], floors: floors || null },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The label the page is expected to render the rank THROUGH. Distinct from the
|
|
73
|
+
// bare rank key on purpose: if a test passed when the page printed the raw key,
|
|
74
|
+
// it would also pass on an instance that renamed its ranks and got the old word.
|
|
75
|
+
const LABELS = { archon: 'Archon', metic: 'Metic', thetes: 'Thetes', xenos: 'Xenos' };
|
|
76
|
+
|
|
77
|
+
// ===========================================================================
|
|
78
|
+
// Boot the real page, scripting one answer per route.
|
|
79
|
+
// ===========================================================================
|
|
80
|
+
|
|
81
|
+
async function boot({ answers = {}, fallback = ok({}) } = {}) {
|
|
82
|
+
const api = {
|
|
83
|
+
async request(method, p) {
|
|
84
|
+
const url = String(p).replace('/api/bongos', '');
|
|
85
|
+
const route = url.split('?')[0];
|
|
86
|
+
if (method === 'GET' && route === '/me') {
|
|
87
|
+
return ok({ builder: { id: 1, rank: 'metic', github_login: 'someone' } });
|
|
88
|
+
}
|
|
89
|
+
if (Object.prototype.hasOwnProperty.call(answers, route)) return answers[route];
|
|
90
|
+
return fallback;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const documentObj = {
|
|
95
|
+
readyState: 'complete',
|
|
96
|
+
body: makeEl('body'),
|
|
97
|
+
documentElement: makeEl('html'),
|
|
98
|
+
createElement: (tag) => makeEl(tag),
|
|
99
|
+
getElementById(id) { return this.body.querySelector(`#${id}`); },
|
|
100
|
+
querySelector(sel) { return this.body.querySelector(sel); },
|
|
101
|
+
querySelectorAll(sel) { return this.body.querySelectorAll(sel); },
|
|
102
|
+
addEventListener() {},
|
|
103
|
+
};
|
|
104
|
+
parseInto(documentObj.body, PAGE);
|
|
105
|
+
|
|
106
|
+
const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search: '', hash: '', assign() {} };
|
|
107
|
+
|
|
108
|
+
const kit = {
|
|
109
|
+
makeTabs: () => ({}),
|
|
110
|
+
makeLedger: () => ({ render() {} }),
|
|
111
|
+
makePager: () => ({ page: 1, perPage: 25 }),
|
|
112
|
+
emptyStateHtml: (msg) => `<div class="empty-state"><p>${escapeHtml(msg)}</p></div>`,
|
|
113
|
+
makeFilterBar: (o) => ({ el: o.mount, values: () => ({}) }),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const windowObj = {
|
|
117
|
+
__BRANDING__: { project: { joinability: 'apply' } },
|
|
118
|
+
OTB: {
|
|
119
|
+
escapeHtml,
|
|
120
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
121
|
+
num: (v) => Number(v),
|
|
122
|
+
fmtInt: (n) => String(n),
|
|
123
|
+
fmtCompact: (n) => String(n),
|
|
124
|
+
fmtDate: (iso) => String(iso).slice(0, 10),
|
|
125
|
+
rankLabel: (r) => LABELS[String(r)] || '',
|
|
126
|
+
toast: () => {},
|
|
127
|
+
},
|
|
128
|
+
OTBKit: kit,
|
|
129
|
+
OTBBranding: { currencyLabel: () => 'Credits' },
|
|
130
|
+
BongosClient: { createClient: () => api },
|
|
131
|
+
location: locationObj,
|
|
132
|
+
addEventListener() {},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const sandbox = {
|
|
136
|
+
window: windowObj, document: documentObj, location: locationObj,
|
|
137
|
+
console: { log() {}, warn() {}, error() {} },
|
|
138
|
+
setTimeout, clearTimeout, Promise, Date, Math, JSON, Error, isNaN,
|
|
139
|
+
Number, String, Object, Array, Boolean, Set, Map, RegExp,
|
|
140
|
+
encodeURIComponent, decodeURIComponent, parseInt, parseFloat,
|
|
141
|
+
};
|
|
142
|
+
sandbox.globalThis = sandbox;
|
|
143
|
+
vm.createContext(sandbox);
|
|
144
|
+
vm.runInContext(SRC, sandbox, { filename: 'watch.js' });
|
|
145
|
+
await settle();
|
|
146
|
+
|
|
147
|
+
const sealed = (id) => {
|
|
148
|
+
const el = documentObj.getElementById(id);
|
|
149
|
+
const p = el && el.querySelector('.watch-unavailable');
|
|
150
|
+
return p ? p.textContent : null;
|
|
151
|
+
};
|
|
152
|
+
return { sealed, documentObj };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ===========================================================================
|
|
156
|
+
// The floor comes from the denial
|
|
157
|
+
// ===========================================================================
|
|
158
|
+
|
|
159
|
+
await test('an archon-floored panel still says Archon', async () => {
|
|
160
|
+
// access_request.review really is archon-floored, so the old copy happened to
|
|
161
|
+
// be right HERE. The fix must not "correct" a sentence that was already true.
|
|
162
|
+
const h = await boot({ answers: { '/access-requests': denied({ 'access_request.review': 'archon' }) } });
|
|
163
|
+
const msg = h.sealed('gate-body');
|
|
164
|
+
assert.ok(msg, 'the access-requests panel rendered no sealed message');
|
|
165
|
+
assert.match(msg, /^Archon and up\./, `expected the archon floor, got: ${msg}`);
|
|
166
|
+
assert.match(msg, /Access requests are not visible at your rank\./, `the subject clause was lost: ${msg}`);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await test('a metic-floored panel says Metic — the defect, in one assertion', async () => {
|
|
170
|
+
// This is the sentence a metic used to read as "Archons only" on a page they
|
|
171
|
+
// could open, having been told the ladder was something it is not.
|
|
172
|
+
const h = await boot({ answers: { '/audit-log': denied({ 'audit_log.read': 'metic' }) } });
|
|
173
|
+
const msg = h.sealed('audit-body');
|
|
174
|
+
assert.ok(msg, 'the audit-log panel rendered no sealed message');
|
|
175
|
+
assert.match(msg, /^Metic and up\./, `expected the metic floor, got: ${msg}`);
|
|
176
|
+
assert.doesNotMatch(msg, /Archon/, `the retired floor came back: ${msg}`);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test('two panels denied at DIFFERENT floors say different things', async () => {
|
|
180
|
+
// The whole reason the floor is read rather than written: one page, two true
|
|
181
|
+
// answers. A blanket rewrite in either direction fails this test.
|
|
182
|
+
const h = await boot({
|
|
183
|
+
answers: {
|
|
184
|
+
'/access-requests': denied({ 'access_request.review': 'archon' }),
|
|
185
|
+
'/builders/roster': denied({ 'builder.roster.read': 'metic' }),
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
assert.match(h.sealed('gate-body'), /^Archon and up\./);
|
|
189
|
+
assert.match(h.sealed('roster-body'), /^Metic and up\./);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ===========================================================================
|
|
193
|
+
// The honest silences
|
|
194
|
+
// ===========================================================================
|
|
195
|
+
|
|
196
|
+
await test('a denial carrying NO floor names no rank at all', async () => {
|
|
197
|
+
// The government port is optional (ADR 0083), so `floors` can legitimately be
|
|
198
|
+
// absent. Naming a rank anyway is guessing, and guessing is what this task is.
|
|
199
|
+
const h = await boot({ answers: { '/audit-log': { status: 403, ok: false, data: null } } });
|
|
200
|
+
const msg = h.sealed('audit-body');
|
|
201
|
+
assert.equal(msg, 'The audit log is not visible at your rank.',
|
|
202
|
+
`a floorless denial must render the subject alone, got: ${msg}`);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await test('a denial on keys with CONFLICTING floors names no rank', async () => {
|
|
206
|
+
// A multi-atom gate can deny on two keys with different floors. There is no
|
|
207
|
+
// single honest sentence, so the panel says none rather than picking the one
|
|
208
|
+
// that happens to sort first.
|
|
209
|
+
const h = await boot({
|
|
210
|
+
answers: { '/audit-log': denied({ 'audit_log.read': 'metic', 'something.else': 'archon' }) },
|
|
211
|
+
});
|
|
212
|
+
const msg = h.sealed('audit-body');
|
|
213
|
+
assert.equal(msg, 'The audit log is not visible at your rank.',
|
|
214
|
+
`an ambiguous floor must not be resolved by guessing, got: ${msg}`);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ===========================================================================
|
|
218
|
+
// The literal cannot come back
|
|
219
|
+
// ===========================================================================
|
|
220
|
+
|
|
221
|
+
await test('no sealed message on this page hardcodes a rank name', async () => {
|
|
222
|
+
// The source half of the claim: a future edit that re-writes "Archons only."
|
|
223
|
+
// into a sealedMsg reds here even if it never renders in a test.
|
|
224
|
+
const strings = [...SRC.matchAll(/sealedMsg:\s*'([^']*)'/g)].map((m) => m[1]);
|
|
225
|
+
assert.ok(strings.length >= 10, `expected the page's sealed messages, found ${strings.length}`);
|
|
226
|
+
for (const s of strings) {
|
|
227
|
+
assert.doesNotMatch(s, /Archon|Metic|Thetes|Xenos/i,
|
|
228
|
+
`a sealed message names a rank in its own text: "${s}". The rank comes from `
|
|
229
|
+
+ 'the 403, or the sentence does not claim one.');
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await test('the 403 path carries the floor rather than discarding the body', async () => {
|
|
234
|
+
// fetchJson used to throw a bare { kind: '403' }, which is what made every
|
|
235
|
+
// panel's sentence a guess. Pin the shape, not just the rendered output.
|
|
236
|
+
assert.match(SRC, /details\.floors/,
|
|
237
|
+
'fetchJson must read details.floors off the denial — otherwise the page is '
|
|
238
|
+
+ 'back to writing the floor itself');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
summary('watch sealed floor (task 1003449)');
|