@bongos/core 1.19.1044 → 1.19.1046
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 +106 -121
- package/docs/adr/0337-the-hub-account-is-the-person-a-hall-account-is-their-seat-in-one-project.md +14 -2
- package/docs/adr/README.md +1 -1
- package/docs/copy-inventory.md +142 -158
- package/docs/copy-registry.json +173 -360
- package/docs/file-map.md +2 -3
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/windows-builders.md +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/claim-action.js +250 -0
- package/modules/hall-ui/public/collab.css +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals-page.js +28 -44
- package/modules/hall-ui/public/goals-render.js +7 -28
- package/modules/hall-ui/public/goals.html +6 -3
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/{people.css → roster.css} +7 -11
- package/modules/hall-ui/public/roster.html +5 -5
- package/modules/hall-ui/public/roster.js +6 -7
- package/modules/hall-ui/public/roster.states.json +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/settings.js +2 -2
- package/modules/hall-ui/public/shell.js +3 -10
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +2 -9
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +5 -2
- package/modules/hall-ui/public/work.js +35 -123
- package/modules/hall-ui/records/builders-roster.md +1 -1
- package/modules/hall-ui/records/roster-and-ledger.md +2 -2
- package/modules/platform-identity/account-visibility.js +3 -3
- package/modules/platform-identity/platform-identity.js +2 -2
- package/modules/platform-identity/routes/profile.js +1 -1
- package/modules/platform-identity/routes/sso.js +4 -3
- package/modules/platform-identity/scouting-authz.js +2 -2
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/auth.js +9 -24
- package/src/bongos/serve-internal.js +13 -19
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +1 -1
- package/tests/auth_resolve_failure.mjs +6 -6
- package/tests/claim_action.mjs +417 -0
- package/tests/core_upgrade_owner_door.mjs +1 -1
- package/tests/edit_hints.mjs +8 -8
- package/tests/effective_visibility_predicate.mjs +1 -1
- package/tests/goal_category_ui.mjs +3 -0
- package/tests/goal_criterion_confirm_ui.mjs +3 -0
- package/tests/goal_forest_page.mjs +3 -0
- package/tests/goal_invite_consent.mjs +3 -1
- package/tests/goal_mine_lens.mjs +3 -0
- package/tests/government_parity.mjs +1 -1
- package/tests/hall_builders_page_pass.mjs +2 -2
- package/tests/hall_mine_lens.mjs +3 -0
- package/tests/hall_nav.mjs +8 -8
- package/tests/hall_page_gate_map.mjs +14 -11
- package/tests/hall_roster_world.mjs +47 -78
- package/tests/module_contributions.mjs +0 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/{scouting_page_gate.mjs → page_gate_second_door.mjs} +34 -29
- package/tests/profile_rollup_consent.mjs +4 -4
- package/tests/profile_route.mjs +3 -26
- package/tests/scouting_owner_access_and_reach.mjs +4 -8
- package/modules/hall-ui/public/people.html +0 -79
- package/modules/hall-ui/public/people.js +0 -321
- package/modules/hall-ui/public/people.states.json +0 -89
- package/tests/people_directory_clip.mjs +0 -269
- package/tests/people_name_only.mjs +0 -263
|
@@ -36,7 +36,6 @@ const SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'ut
|
|
|
36
36
|
// functions in src/bongos/auth.js):
|
|
37
37
|
// requireBuilderPage page.view.public any signed-in builder (xenos+)
|
|
38
38
|
// requireGovernmentPage page.view.government metic+ (ADR 0157)
|
|
39
|
-
// requireProjectCuratePage project.curate metic+
|
|
40
39
|
// requireBoardVotePage board.vote.cast xenos+ (ADR 0266)
|
|
41
40
|
// That list is now CHECKED, not just written: "every page gate is dispatched"
|
|
42
41
|
// below reads the gate table out of auth.js and fails if a gate here guards no
|
|
@@ -107,12 +106,13 @@ const ORACLE = {
|
|
|
107
106
|
// The fleet cost page (task 1001949), on the atom its one read checks.
|
|
108
107
|
'/builders/fleet': 'requireFleetPage',
|
|
109
108
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// legacy twin /
|
|
113
|
-
// /
|
|
114
|
-
//
|
|
115
|
-
|
|
109
|
+
// Gone (task 1003444, owner call 2026-09-25 — ADR 0337 D5): the cross-project
|
|
110
|
+
// directory left the hall, and /people now redirects to /roster before this
|
|
111
|
+
// dispatch runs, like its legacy twin /scouting (task 1003440).
|
|
112
|
+
// tests/hall_roster_world.mjs owns the proof that the redirect is there. The
|
|
113
|
+
// page gate that fronted it (requireProjectCuratePage) went with it; what
|
|
114
|
+
// still holds is GET /scouting's own gate, in the platform-identity module.
|
|
115
|
+
'/builders/people': null,
|
|
116
116
|
|
|
117
117
|
// Metic+ since R16 (goal 1000069): the Government page is three rooms —
|
|
118
118
|
// Permissions · Board Room · Constitution (reordered, task 1003269) — and only
|
|
@@ -138,8 +138,8 @@ const ORACLE = {
|
|
|
138
138
|
// /roster joins them (task 1003441): it is this project’s own ledger, and its
|
|
139
139
|
// one data source — GET /leaderboard — is deliberately unauthenticated,
|
|
140
140
|
// mirroring the public status surface. The page must not be gated tighter than
|
|
141
|
-
// the API it fronts, which is the same rule that puts /
|
|
142
|
-
//
|
|
141
|
+
// the API it fronts, which is the same rule that puts /deploy at
|
|
142
|
+
// core.pin.move. It was reachable to a stranger as the #leaderboard view on
|
|
143
143
|
// the ungated index, so this preserves the audience rather than widening it.
|
|
144
144
|
'/builders/roster': null,
|
|
145
145
|
'/builders/': null,
|
|
@@ -310,6 +310,9 @@ const GONE_PAGES = {
|
|
|
310
310
|
// Task 1004231 — the ideator's sky replaced both the room and the old sky with
|
|
311
311
|
// one page at /thinking; /sky is a 301 to it, not a page.
|
|
312
312
|
'/builders/sky': 'task 1004231 — the sky lives at /thinking now; /sky redirects there',
|
|
313
|
+
// Task 1003444 — the owner deleted the hall's cross-project directory; the hub
|
|
314
|
+
// community page (task 1003363) is its home, and /people redirects to /roster.
|
|
315
|
+
'/builders/people': 'task 1003444 — the hall has no cross-project directory; /people redirects to /roster',
|
|
313
316
|
};
|
|
314
317
|
|
|
315
318
|
test('a row that is null because the page left the hall serves no page', () => {
|
|
@@ -344,8 +347,8 @@ const AUTH_SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/auth.js'), 'utf8');
|
|
|
344
347
|
function readPageGateNames(src) {
|
|
345
348
|
const table = /const PAGE_GATES = Object\.freeze\(\{([\s\S]*?)\n\}\);/.exec(src);
|
|
346
349
|
assert.ok(table, 'PAGE_GATES is still a `const PAGE_GATES = Object.freeze({…});` literal');
|
|
347
|
-
// Entry keys sit at exactly one indent level; `permission:` / `deniedTo:`
|
|
348
|
-
//
|
|
350
|
+
// Entry keys sit at exactly one indent level; `permission:` / `deniedTo:`
|
|
351
|
+
// are nested deeper, so the two-space anchor keeps them out.
|
|
349
352
|
return [...table[1].matchAll(/^ {2}(require\w+):/gm)].map((m) => m[1]);
|
|
350
353
|
}
|
|
351
354
|
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// in the open and the rest behind the kit's disclosure, the readiness is the
|
|
6
6
|
// family's figure band in the same dot language as the roll line, the view
|
|
7
7
|
// toggle is a view and not a filter, the accent lands on the one live door; the
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// cross-project directory is gone from the hall — /scouting was deleted by task
|
|
9
|
+
// 1003440 and /people by task 1003444, and both URLs now land on /roster; the
|
|
10
|
+
// session ledger drops the nowrap + wrapper pair that broke its sticky header,
|
|
11
|
+
// makes every id a door, and names its floor from the pack.
|
|
12
12
|
import test from 'node:test';
|
|
13
13
|
import assert from 'node:assert/strict';
|
|
14
14
|
import fs from 'node:fs';
|
|
@@ -156,8 +156,8 @@ test('goals: the card is the row folded — no box, one fact source, the subtitl
|
|
|
156
156
|
assert.match(read('goals-page.js'), /gr-row gr-row--skel/, 'the boot skeleton is row-shaped');
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
test('goals.css and
|
|
160
|
-
for (const f of ['goals.css', '
|
|
159
|
+
test('goals.css and roster.css spend only the fifteen, and every token they name exists', () => {
|
|
160
|
+
for (const f of ['goals.css', 'roster.css']) {
|
|
161
161
|
const css = read(f);
|
|
162
162
|
noAlias(css, f);
|
|
163
163
|
tokensDefined(css, f);
|
|
@@ -165,89 +165,61 @@ test('goals.css and people.css spend only the fifteen, and every token they name
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
// =========================================================================
|
|
168
|
-
// PEOPLE —
|
|
168
|
+
// PEOPLE — deleted from the hall; both old doors land on /roster
|
|
169
169
|
// =========================================================================
|
|
170
170
|
|
|
171
|
-
// Task
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
test('the legacy /scouting door
|
|
176
|
-
for (const f of ['scouting.html', 'scouting.js', 'scouting.states.json', 'scouting.css'
|
|
177
|
-
|
|
171
|
+
// Task 1003440 deleted the legacy /scouting door; task 1003444 deleted /people
|
|
172
|
+
// itself (owner call, 2026-09-25 evening — ADR 0337 D5): cross-project discovery
|
|
173
|
+
// belongs to the hub, and the hall's directory is /roster. Both URLs still have
|
|
174
|
+
// to answer, or a bookmark 404s.
|
|
175
|
+
test('the /people page and its legacy /scouting door are deleted, and nothing of either is left in the hall', () => {
|
|
176
|
+
for (const f of ['scouting.html', 'scouting.js', 'scouting.states.json', 'scouting.css',
|
|
177
|
+
'people.html', 'people.js', 'people.states.json', 'people.css']) {
|
|
178
|
+
assert.ok(!fs.existsSync(path.join(PUB, f)), f + ' is gone — the hall carries no cross-project directory');
|
|
178
179
|
}
|
|
179
180
|
});
|
|
180
181
|
|
|
181
|
-
test('the /scouting
|
|
182
|
+
test('the /people and /scouting URLs redirect to /roster, and no gate dispatches to either', () => {
|
|
182
183
|
const serve = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'), 'utf8');
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
const decl = 'const RETIRED_DIRECTORY_RE = ';
|
|
185
|
+
const i = serve.indexOf(decl);
|
|
186
|
+
assert.ok(i > 0, 'the retired URLs keep a matcher, so a bookmark still lands somewhere');
|
|
187
|
+
const block = serve.slice(i, i + 900);
|
|
188
|
+
// 302, not 301: /roster is the interim target until the hub community page
|
|
189
|
+
// (task 1003363) ships, and a browser caches a 301 past any re-point.
|
|
190
|
+
assert.ok(block.includes('res.redirect(302,'), 'and answers with a temporary redirect');
|
|
191
|
+
assert.ok(block.includes('/roster'), 'pointing at the in-project directory that remains');
|
|
188
192
|
// RUN the shipped matcher, rather than trusting that it reads right. Anchoring
|
|
189
193
|
// is the half that goes wrong: an unanchored twin would swallow the API route.
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
for (const p of ['/builders/scouting', '/builders/scouting/', '/builders/scouting.html']) {
|
|
194
|
+
const rx = eval(serve.slice(i + decl.length, serve.indexOf(';', i))); // eslint-disable-line no-eval
|
|
195
|
+
for (const p of ['/builders/people', '/builders/people/', '/builders/people.html',
|
|
196
|
+
'/builders/scouting', '/builders/scouting/', '/builders/scouting.html']) {
|
|
194
197
|
assert.ok(rx.test(p), p + ' must reach the redirect');
|
|
195
198
|
}
|
|
196
|
-
for (const p of ['/api/gds/scouting', '/api/bongos/scouting', '/builders/
|
|
197
|
-
'/builders/scoutingfoo', '/builders/scouting/extra']) {
|
|
198
|
-
assert.ok(!rx.test(p), p + ' must NOT be redirected — the data route and the
|
|
199
|
+
for (const p of ['/api/gds/scouting', '/api/bongos/scouting', '/builders/roster',
|
|
200
|
+
'/builders/peoplefoo', '/builders/people/extra', '/builders/scoutingfoo', '/builders/scouting/extra']) {
|
|
201
|
+
assert.ok(!rx.test(p), p + ' must NOT be redirected — the data route and the roster are not the door');
|
|
199
202
|
}
|
|
200
203
|
// The gate is the half that would rot silently: a page-gate branch still
|
|
201
204
|
// naming a deleted page is a wall guarding nothing.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test('the directory page loads its own script, sheet and toolkit', () => {
|
|
207
|
-
const people = read('people.html');
|
|
208
|
-
for (const html of [people]) {
|
|
209
|
-
assert.match(html, /\/builders\/people\.js/);
|
|
210
|
-
assert.match(html, /\/builders\/people\.css/);
|
|
211
|
-
assert.match(html, /\/builders\/dom-utils\.js/, 'the toolkit the rank label and the number formats come from');
|
|
212
|
-
assert.match(html, /<h1 class="page-head__h">People<\/h1>/);
|
|
205
|
+
const code = decomment(serve);
|
|
206
|
+
for (const gone of ['SCOUTING_PAGE_RE', 'PEOPLE_PAGE_RE', 'SCOUTING_LEGACY_RE', 'requireProjectCuratePage']) {
|
|
207
|
+
assert.ok(!code.includes(gone), `${gone} is gone — no gate dispatches to a page that no longer exists`);
|
|
213
208
|
}
|
|
214
209
|
});
|
|
215
210
|
|
|
216
|
-
test('
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
assert.
|
|
224
|
-
// The
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
// without pinning the defect that happened to be inside it.
|
|
229
|
-
assert.match(src, /<span class="fact-chip">' \+ esc\(roleLabel\(d\)\)/, 'roles are chips, spoken not printed');
|
|
230
|
-
assert.doesNotMatch(src, /class="num"/, 'the shell\'s ledger__num, not the bare .num scouting.js had corrected');
|
|
231
|
-
assert.match(src, /class="ledger__num"/);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test('the directory has a search, a sort and a craft lens on the kit bar, and pages through the kit', () => {
|
|
235
|
-
const src = read('people.js');
|
|
236
|
-
assert.match(src, /kit\.makeFilterBar\(\{/);
|
|
237
|
-
assert.match(src, /search: \{ id: 'people-q'/);
|
|
238
|
-
assert.match(src, /\{ id: 'people-sort', label: 'Sort'/);
|
|
239
|
-
// task 1003936: the axis label is read from OTB.roleAxisLabel(), never typed.
|
|
240
|
-
assert.match(src, /\{ id: 'people-craft', label: window\.OTB\.roleAxisLabel\(\)/);
|
|
241
|
-
assert.match(src, /kit\.makePager\('people'/);
|
|
242
|
-
assert.match(src, /kit\.makeLedger\(\{/);
|
|
243
|
-
assert.doesNotMatch(src, /createElement\('table'\)/, 'no hand-built table');
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
test('the directory names its floor from the pack, and never says Archon-only', () => {
|
|
247
|
-
const src = read('people.js');
|
|
248
|
-
assert.match(src, /rankLabel\('metic'\)/);
|
|
249
|
-
assert.doesNotMatch(decomment(src), /Archon-only/);
|
|
250
|
-
assert.doesNotMatch(read('people.html'), /Archon/);
|
|
211
|
+
test('no hall page links to /people or loads its assets', () => {
|
|
212
|
+
const offenders = [];
|
|
213
|
+
for (const f of fs.readdirSync(PUB).filter((n) => /\.(html|js|css)$/.test(n))) {
|
|
214
|
+
const src = decomment(read(f)).replace(/<!--[\s\S]*?-->/g, '');
|
|
215
|
+
if (/['"(=]\/(builders\/)?people(\.html)?\/?['"?#)\s>]/.test(src)) offenders.push(`${f}: a /people link`);
|
|
216
|
+
if (/people\.(css|js)\b/.test(src)) offenders.push(`${f}: a people.css/people.js reference`);
|
|
217
|
+
}
|
|
218
|
+
assert.deepEqual(offenders, [], 'a link to the deleted page would bounce off the redirect for no reason');
|
|
219
|
+
// The roster's identity cell moved with it: the sheet is the roster's own now.
|
|
220
|
+
const roster = read('roster.html');
|
|
221
|
+
assert.match(roster, /\/builders\/roster\.css/, 'roster.html loads its own sheet');
|
|
222
|
+
assert.doesNotMatch(roster, /cross-project directory/, 'and no longer contrasts itself with a page the hall no longer has');
|
|
251
223
|
});
|
|
252
224
|
|
|
253
225
|
// =========================================================================
|
|
@@ -304,7 +276,7 @@ test('sessions: the floor is written from the pack\'s rank label, never a hardco
|
|
|
304
276
|
// The harness carries what the pages read, and the states files ask for the new states
|
|
305
277
|
// =========================================================================
|
|
306
278
|
|
|
307
|
-
test('the harness feeds: rollups for every goal
|
|
279
|
+
test('the harness feeds: rollups for every goal and the category set', () => {
|
|
308
280
|
const goals = readJson('goals.json').goals;
|
|
309
281
|
const rollups = readJson('goals__rollups.json').rollups;
|
|
310
282
|
const byGoal = new Set(rollups.map((r) => String(r.goal_id)));
|
|
@@ -315,16 +287,13 @@ test('the harness feeds: rollups for every goal, the category set, avatars on th
|
|
|
315
287
|
assert.ok(goals.some((g) => g.category && g.category.slug), 'some goals carry a category');
|
|
316
288
|
const cats = readJson('work-categories.json').categories;
|
|
317
289
|
assert.ok(cats.length >= 2 && cats.every((c) => c.slug && c.label));
|
|
318
|
-
|
|
319
|
-
assert.ok(dir.some((b) => b.avatar_url) && dir.some((b) => !b.avatar_url), 'the directory fixture has builders with and without an avatar');
|
|
290
|
+
assert.ok(!fs.existsSync(path.join(FIX, 'scouting.json')), 'the directory fixture left with the only page that read it');
|
|
320
291
|
});
|
|
321
292
|
|
|
322
293
|
test('the states files exercise the redesign\'s new states', () => {
|
|
323
294
|
const goals = JSON.parse(read('goals.states.json'));
|
|
324
295
|
assert.ok(goals.states.filters && goals.states.cards, 'goals: the Filters disclosure and the cards view are measured');
|
|
325
296
|
assert.deepEqual(goals.states.cards.expect.value, [['#f-goal-view', 'cards']]);
|
|
326
|
-
const people = JSON.parse(read('people.states.json'));
|
|
327
|
-
assert.ok(people.states.search, 'people: a search is measured');
|
|
328
297
|
const sessions = JSON.parse(read('sessions.states.json'));
|
|
329
298
|
assert.deepEqual(sessions.states.ledger.expect.visible, ['#stats-scroll', '#sessions-scroll']);
|
|
330
299
|
});
|
|
@@ -227,7 +227,6 @@ test('the role axis has exactly one user-facing word, and no surface types it',
|
|
|
227
227
|
const SURFACES = [
|
|
228
228
|
'modules/hall-ui/public/work.js',
|
|
229
229
|
'modules/hall-ui/public/goals-page.js',
|
|
230
|
-
'modules/hall-ui/public/people.js',
|
|
231
230
|
'modules/hall-ui/public/profile.js',
|
|
232
231
|
'modules/hall-ui/public/builders.js',
|
|
233
232
|
];
|
|
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
87
87
|
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
88
|
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
89
|
|
|
90
|
-
const SHELL_SHA = '
|
|
90
|
+
const SHELL_SHA = '6b28297270d1e1f0';
|
|
91
91
|
|
|
92
92
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
93
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
// tests/
|
|
1
|
+
// tests/page_gate_second_door.mjs
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
3
|
+
// The page gates' `alsoAdmits` SECOND DOOR (ADR 0210), proven on the one gate that
|
|
4
|
+
// declares it: requireCorePinPage, the /deploy shell, whose routes admit a hosted
|
|
5
|
+
// project's own owner beside `core.pin.move` (ADR 0339, task 1004174). The door's
|
|
6
|
+
// first user was the hall's /people page; task 1003444 deleted that page (ADR 0337
|
|
7
|
+
// D5) and retargeted this file (formerly tests/scouting_page_gate.mjs) here.
|
|
5
8
|
//
|
|
6
|
-
// WHY THIS IS ITS OWN FILE
|
|
9
|
+
// WHY THIS IS ITS OWN FILE. `runPageGate` grew an optional
|
|
7
10
|
// `alsoAdmits` door — new authorization code that runs on EVERY page-gate
|
|
8
11
|
// denial, for every gate. The route proof
|
|
9
|
-
// (tests/
|
|
12
|
+
// (tests/core_upgrade_owner_door.mjs) proves the widening itself; this file pins
|
|
13
|
+
// the door's failure modes, which a route proof cannot reach: page gates are
|
|
10
14
|
// driven by a session, and that file's ESM imports pull `auth.js` in through the
|
|
11
15
|
// module graph before any session double could be installed. This file installs
|
|
12
16
|
// the double FIRST, exactly as tests/government_page_gates.mjs does, and so gets
|
|
@@ -21,12 +25,12 @@
|
|
|
21
25
|
// 3. every way the second door can fail — module absent, function absent, a
|
|
22
26
|
// throw, a non-boolean, and a name that resolves only through the prototype
|
|
23
27
|
// — fails CLOSED. A fail-open default in an authz path is the bug class;
|
|
24
|
-
// 4. the other
|
|
28
|
+
// 4. the other gates are untouched, and their seam is never even
|
|
25
29
|
// consulted, because they declare no second door.
|
|
26
30
|
//
|
|
27
31
|
// DB-free: a session double and a port double. No Postgres, no HTTP.
|
|
28
32
|
//
|
|
29
|
-
// Run: node tests/
|
|
33
|
+
// Run: node tests/page_gate_second_door.mjs
|
|
30
34
|
|
|
31
35
|
import { strict as assert } from 'node:assert';
|
|
32
36
|
import { createRequire } from 'node:module';
|
|
@@ -53,12 +57,13 @@ async function t(name, fn) {
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
// The cast. `rank` is the caller's rank ON THE HUB; `github_id` is the account
|
|
56
|
-
// whose project memberships the ownership door asks about. The
|
|
57
|
-
//
|
|
58
|
-
//
|
|
60
|
+
// whose project memberships the ownership door asks about. The first row is the
|
|
61
|
+
// point: a project's registrant is an Archon on their OWN instance and, on the
|
|
62
|
+
// hub, very often a xenos holding nothing at all.
|
|
59
63
|
const OWNER = { builder_id: 1, github_id: '9001', rank: 'xenos' };
|
|
60
64
|
const STRANGER = { builder_id: 2, github_id: '9002', rank: 'xenos' };
|
|
61
|
-
|
|
65
|
+
// core.pin.move is archon-floor (ADR 0293), so the permission-door control is an Archon.
|
|
66
|
+
const OPERATOR = { builder_id: 5, github_id: '9005', rank: 'archon' };
|
|
62
67
|
|
|
63
68
|
// The ownership answer, as the platform-identity.hub port supplies it.
|
|
64
69
|
const OWNS = new Set(['9001']);
|
|
@@ -94,33 +99,33 @@ async function runGate(gate, session, hub) {
|
|
|
94
99
|
return { res, nexted };
|
|
95
100
|
}
|
|
96
101
|
|
|
97
|
-
const
|
|
102
|
+
const DENIED_PIN = { code: 302, loc: '/builders?denied=core.pin.move' };
|
|
98
103
|
|
|
99
|
-
console.log('the /
|
|
104
|
+
console.log('the /deploy shell follows its data routes:');
|
|
100
105
|
|
|
101
106
|
await t('a PROJECT OWNER reaches the shell, holding no permission at all', async () => {
|
|
102
|
-
const { nexted } = await runGate('
|
|
107
|
+
const { nexted } = await runGate('requireCorePinPage', OWNER, workingHub);
|
|
103
108
|
assert.ok(nexted,
|
|
104
109
|
'the page gate must admit the audience its data route admits — otherwise the widening is real in the API and unreachable in the product');
|
|
105
110
|
});
|
|
106
111
|
|
|
107
112
|
await t('CONTROL: the permission door still opens the shell on its own', async () => {
|
|
108
|
-
const { nexted } = await runGate('
|
|
109
|
-
assert.ok(nexted, '
|
|
113
|
+
const { nexted } = await runGate('requireCorePinPage', OPERATOR, workingHub);
|
|
114
|
+
assert.ok(nexted, 'core.pin.move admits an Archon (ADR 0293); ADR 0339 did not move it');
|
|
110
115
|
// ...and it does not need the second door at all.
|
|
111
|
-
const { nexted: noHub } = await runGate('
|
|
116
|
+
const { nexted: noHub } = await runGate('requireCorePinPage', OPERATOR, null);
|
|
112
117
|
assert.ok(noHub, 'an operator gets in on a vanilla instance with no hub module');
|
|
113
118
|
});
|
|
114
119
|
|
|
115
120
|
await t('a builder who is neither gets the byte-identical redirect the gate always sent', async () => {
|
|
116
|
-
const { res, nexted } = await runGate('
|
|
117
|
-
assert.equal(nexted, false, '
|
|
118
|
-
assert.deepEqual(res._redirect,
|
|
121
|
+
const { res, nexted } = await runGate('requireCorePinPage', STRANGER, workingHub);
|
|
122
|
+
assert.equal(nexted, false, 'a xenos who owns no project stays out');
|
|
123
|
+
assert.deepEqual(res._redirect, DENIED_PIN,
|
|
119
124
|
'the denial keeps naming the permission (task 1002613), so the hall shell can still render WHY');
|
|
120
125
|
});
|
|
121
126
|
|
|
122
127
|
await t('an unauthenticated visitor is still bounced into sign-in, never to the second door', async () => {
|
|
123
|
-
const { res, nexted } = await runGate('
|
|
128
|
+
const { res, nexted } = await runGate('requireCorePinPage', null, workingHub);
|
|
124
129
|
assert.equal(nexted, false);
|
|
125
130
|
assert.match(res._redirect.loc, /auth\/web\/start/, 'no session means sign-in, not a denial page');
|
|
126
131
|
});
|
|
@@ -139,12 +144,12 @@ await t('every way the second door can fail, it fails CLOSED', async () => {
|
|
|
139
144
|
['the function resolves only through the prototype', Object.create({ isProjectOwner: () => true })],
|
|
140
145
|
];
|
|
141
146
|
for (const [why, hub] of cases) {
|
|
142
|
-
const { res, nexted } = await runGate('
|
|
147
|
+
const { res, nexted } = await runGate('requireCorePinPage', OWNER, hub);
|
|
143
148
|
assert.equal(nexted, false, `${why}: must not admit`);
|
|
144
|
-
assert.deepEqual(res._redirect,
|
|
149
|
+
assert.deepEqual(res._redirect, DENIED_PIN,
|
|
145
150
|
`${why}: must still redirect rather than hang, throw, or answer JSON`);
|
|
146
151
|
}
|
|
147
|
-
const { nexted } = await runGate('
|
|
152
|
+
const { nexted } = await runGate('requireCorePinPage', OWNER, workingHub);
|
|
148
153
|
assert.ok(nexted, 'CONTROL: the same caller IS admitted when the door works — the six refusals are the door, not the caller');
|
|
149
154
|
});
|
|
150
155
|
|
|
@@ -152,7 +157,7 @@ await t('the second door is consulted only where the AUTHORITY answer was no', a
|
|
|
152
157
|
// A permission holder must not pay for it, and — more important — the seam
|
|
153
158
|
// must never be able to NARROW a caller the permission already admitted.
|
|
154
159
|
const spy = { isProjectOwner: () => { throw new Error('the seam must not be consulted for a caller the permission already admitted'); } };
|
|
155
|
-
const { nexted } = await runGate('
|
|
160
|
+
const { nexted } = await runGate('requireCorePinPage', OPERATOR, spy);
|
|
156
161
|
assert.ok(nexted, 'the atom holder is admitted before the second door is reached');
|
|
157
162
|
});
|
|
158
163
|
|
|
@@ -176,14 +181,14 @@ await t('the OTHER page gates are untouched — their seam is never consulted at
|
|
|
176
181
|
|
|
177
182
|
await t('only the gates WITH A PROOF declare a second door (a widening must be opted into, one gate at a time)', () => {
|
|
178
183
|
const src = require('node:fs').readFileSync(new URL('../src/bongos/auth.js', import.meta.url), 'utf8');
|
|
179
|
-
// Keyed by GATE, not just the door's name, so a
|
|
184
|
+
// Keyed by GATE, not just the door's name, so a second gate borrowing 'isProjectOwner'
|
|
180
185
|
// fails here instead of hiding behind an unchanged list of names. Each entry names the
|
|
181
186
|
// file that proves its widening:
|
|
182
|
-
//
|
|
183
|
-
//
|
|
187
|
+
// requireCorePinPage — tests/core_upgrade_owner_door.mjs (ADR 0339, task 1004174).
|
|
188
|
+
// (requireProjectCuratePage, the /people gate, was deleted with its page — task 1003444.)
|
|
184
189
|
const declarations = [...src.matchAll(/(require\w+Page):\s*Object\.freeze\(\{[^}]*alsoAdmits:\s*'([^']+)'/g)]
|
|
185
190
|
.map((m) => `${m[1]}:${m[2]}`);
|
|
186
|
-
assert.deepEqual(declarations, ['
|
|
191
|
+
assert.deepEqual(declarations, ['requireCorePinPage:isProjectOwner'],
|
|
187
192
|
'a second alsoAdmits appearing without its own proof is how a page gate quietly widens');
|
|
188
193
|
const plain = [...src.matchAll(/alsoAdmits:\s*'([^']+)'/g)].length;
|
|
189
194
|
assert.equal(plain, declarations.length, 'every alsoAdmits sits on a named page gate this test can see');
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// unnest(), so the rollup queries need a live Postgres (the task-1002911
|
|
5
5
|
// precedent). Replays the module's actual migrations, then asserts:
|
|
6
6
|
// 1. listScoutingRollup lists ONLY consented + not-hidden builders (D2/D3) —
|
|
7
|
-
// provisional, hidden and never-accepted accounts drop out of
|
|
7
|
+
// provisional, hidden and never-accepted accounts drop out of
|
|
8
8
|
// /scouting by construction. Consent is TWO columns since ADR 0213
|
|
9
9
|
// (profile_state='active' AND terms_accepted_at IS NOT NULL), and the
|
|
10
10
|
// LEGACY fixture is the pre-terms row that proves the second one.
|
|
@@ -151,7 +151,7 @@ try {
|
|
|
151
151
|
[...ALL_CLIENTS, ACTIVE, HIDDEN, PROV, LEGACY],
|
|
152
152
|
);
|
|
153
153
|
|
|
154
|
-
// 1 — the scouting
|
|
154
|
+
// 1 — the scouting predicate (D2/D3).
|
|
155
155
|
const listed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
|
|
156
156
|
ok(listed.includes(ACTIVE), 'active + accepted + visible builder is listed');
|
|
157
157
|
ok(!listed.includes(HIDDEN), 'hide_stats builder drops out of the scouting rollup');
|
|
@@ -209,11 +209,11 @@ try {
|
|
|
209
209
|
|
|
210
210
|
// 4b — the live hide toggle (R03, task 1002279): flipping hide_stats takes
|
|
211
211
|
// effect IMMEDIATELY on every public read — the profile port and the
|
|
212
|
-
// /
|
|
212
|
+
// /scouting rollup — while the self view keeps serving.
|
|
213
213
|
await pi.setAccountHideStats({ githubId: ACTIVE, githubLogin: 'active-b', hide: true }, { pool });
|
|
214
214
|
ok(await pi.getPublicProfileExtras(ACTIVE, { pool }) === null, 'hide ON → public read drops to null immediately');
|
|
215
215
|
const hiddenListed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
|
|
216
|
-
ok(!hiddenListed.includes(ACTIVE), 'hide ON → the builder drops out of /
|
|
216
|
+
ok(!hiddenListed.includes(ACTIVE), 'hide ON → the builder drops out of /scouting immediately');
|
|
217
217
|
const stillOwn = await pi.getOwnProfileExtras(ACTIVE, { pool });
|
|
218
218
|
ok(stillOwn && stillOwn.totals.credits === 175, 'hide ON → the self view still serves their own numbers');
|
|
219
219
|
let hiddenAcct = await pi.getAccount(ACTIVE, { pool });
|
package/tests/profile_route.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// 2. flipping hide_stats removes it on the IMMEDIATE next load, and the
|
|
19
19
|
// response is `Cache-Control: no-store` — the old `public, max-age=60`
|
|
20
20
|
// would keep serving the pre-hide body for a minute (ADR 0171 D5);
|
|
21
|
-
// 3. `/scouting` (the
|
|
21
|
+
// 3. `/scouting` (the recruiting directory's endpoint) drops the
|
|
22
22
|
// hidden builder too — enforcement is on every read path, not the profile;
|
|
23
23
|
// 4. the leak guard BITES: planting a private field turns the key-list and
|
|
24
24
|
// sentinel assertions red (a leak test that cannot fail is not a test);
|
|
@@ -35,14 +35,9 @@
|
|
|
35
35
|
import { strict as assert } from 'node:assert';
|
|
36
36
|
import { createRequire } from 'node:module';
|
|
37
37
|
import http from 'node:http';
|
|
38
|
-
import { readFileSync } from 'node:fs';
|
|
39
|
-
import { fileURLToPath } from 'node:url';
|
|
40
|
-
import { dirname, join } from 'node:path';
|
|
41
38
|
|
|
42
39
|
const require = createRequire(import.meta.url);
|
|
43
40
|
const express = require('express');
|
|
44
|
-
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
45
|
-
const sourceOf = (rel) => readFileSync(join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
|
|
46
41
|
|
|
47
42
|
let passed = 0;
|
|
48
43
|
let failed = 0;
|
|
@@ -410,8 +405,8 @@ const engine = {
|
|
|
410
405
|
const sql = String(text);
|
|
411
406
|
engine.calls.push({ sql, params });
|
|
412
407
|
|
|
413
|
-
// listScoutingRollup — the ONE read behind GET /scouting
|
|
414
|
-
//
|
|
408
|
+
// listScoutingRollup — the ONE read behind GET /scouting. Honors exactly
|
|
409
|
+
// the predicates the query declares.
|
|
415
410
|
if (/AS project_count/.test(sql)) {
|
|
416
411
|
let rows = SCOUTING_TABLE;
|
|
417
412
|
if (/pa\.profile_state = 'active'/.test(sql)) rows = rows.filter((r) => r.profile_state === 'active');
|
|
@@ -666,24 +661,6 @@ await t('R06.3 GET /scouting ranks the visible builder, lists the HIDDEN one by
|
|
|
666
661
|
}
|
|
667
662
|
});
|
|
668
663
|
|
|
669
|
-
await t('R06.3 the /people page reads /scouting and nothing else', async () => {
|
|
670
|
-
// /people is the hall's render of the same endpoint, so whatever /scouting
|
|
671
|
-
// drops, /people drops. Pin that it has no second data source.
|
|
672
|
-
//
|
|
673
|
-
// MATCHED AS AN EXPRESSION, NOT A STRING LITERAL. This used to capture the
|
|
674
|
-
// whole URL out of `fetch('…')` and compare it to one hardcoded string, which
|
|
675
|
-
// pinned the row limit as a side effect of pinning the ENDPOINT — so task
|
|
676
|
-
// 1003110 naming that limit (`DIRECTORY_LIMIT`, the one constant that feeds
|
|
677
|
-
// both the request and the "showing the first N" copy) failed a test about
|
|
678
|
-
// data sources. What is load-bearing here is the count and the destination:
|
|
679
|
-
// ONE read, and it goes to the scouting rollup. How the query string is
|
|
680
|
-
// assembled is the page's business.
|
|
681
|
-
const args = [...sourceOf('modules/hall-ui/public/people.js').matchAll(/\bfetch\(\s*([^,)]+)/g)].map((m) => m[1].trim());
|
|
682
|
-
assert.equal(args.length, 1, '/people has exactly one data source');
|
|
683
|
-
assert.match(args[0], /^'\/api\/bongos\/scouting\?limit='/,
|
|
684
|
-
'and it is the scouting rollup — nothing may re-enter through a second endpoint');
|
|
685
|
-
});
|
|
686
|
-
|
|
687
664
|
scout.server.close();
|
|
688
665
|
server.close();
|
|
689
666
|
|
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
// WHO APPEARS IN IT. The recruiting opt-out (`recruiter_discoverable`, privacy
|
|
13
13
|
// spec D1) gated nothing until this task: a public account that had opted out
|
|
14
14
|
// still appeared. listScoutingRollup now joins scoutingListableSql, so an
|
|
15
|
-
// opted-out builder is absent from /scouting
|
|
16
|
-
//
|
|
17
|
-
// twice — and flipping the toggle back restores them.
|
|
15
|
+
// opted-out builder is absent from /scouting — its one reader's predicate is
|
|
16
|
+
// proved from source below — and flipping the toggle back restores them.
|
|
18
17
|
//
|
|
19
18
|
// AND NEITHER AXIS TOUCHES THE OTHER (ADR 0016). Being scoutable is not
|
|
20
19
|
// authority and opting out is not a punishment: an opted-out builder who owns
|
|
@@ -641,13 +640,10 @@ await t('CALIBRATION: the interpreter refuses what it cannot model, rather than
|
|
|
641
640
|
));
|
|
642
641
|
});
|
|
643
642
|
|
|
644
|
-
await t('
|
|
645
|
-
const page = fs.readFileSync(path.join(repo, 'modules/hall-ui/public/people.js'), 'utf8');
|
|
646
|
-
assert.match(page, /fetch\('\/api\/bongos\/scouting/,
|
|
647
|
-
'the hall directory reads GET /scouting; if it ever grows its own query, this proof stops covering it');
|
|
643
|
+
await t('listScoutingRollup, the one reader behind GET /scouting, joins the composed predicate', async () => {
|
|
648
644
|
const source = fs.readFileSync(path.join(repo, 'modules/platform-identity/platform-identity.js'), 'utf8');
|
|
649
645
|
assert.match(source, /scoutingListableSql\('pa'\)/,
|
|
650
|
-
'
|
|
646
|
+
'the reader joins scoutingListableSql, so "absent from /scouting" holds by construction');
|
|
651
647
|
});
|
|
652
648
|
|
|
653
649
|
// ===========================================================================
|
|
@@ -1,79 +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>People — {{worldName}}</title>
|
|
7
|
-
<meta name="description" content="The builder directory: everyone building across {{worldName}} projects, searchable and ranked by track record.">
|
|
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
|
-
<!-- task 1003470: the identity cell (avatar, name, login) is the one thing the
|
|
13
|
-
directory adds to the shell's ledger. -->
|
|
14
|
-
<link rel="stylesheet" href="/builders/people.css">
|
|
15
|
-
</head>
|
|
16
|
-
<body data-page="people" data-page-title="People">
|
|
17
|
-
|
|
18
|
-
<div class="app">
|
|
19
|
-
<aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
|
|
20
|
-
<div class="app-scrim" id="app-scrim" hidden></div>
|
|
21
|
-
<div class="app-body">
|
|
22
|
-
<header class="app-topbar" id="app-topbar"></header>
|
|
23
|
-
<div class="app-content">
|
|
24
|
-
<!-- The board page head (task 1003307): the label voice, because on a
|
|
25
|
-
working surface the page's own name is the least interesting thing on
|
|
26
|
-
it. The sub is rewritten by people.js once the directory has loaded. -->
|
|
27
|
-
<div class="page-head page-head--board">
|
|
28
|
-
<h1 class="page-head__h">People</h1>
|
|
29
|
-
<p class="page-head__sub" id="people-sub">Everyone building across hosted projects, ranked by track record. Descriptive only — cross-project numbers never carry authority here.</p>
|
|
30
|
-
</div>
|
|
31
|
-
|
|
32
|
-
<main id="hall-main">
|
|
33
|
-
<!-- task 1003470: ONE directory. Its legacy door /scouting rendered this
|
|
34
|
-
same page from the same script, and task 1003440 deleted it — the same
|
|
35
|
-
/api/bongos/scouting rollup used to be drawn three different ways. -->
|
|
36
|
-
<section class="scroll" id="people-scroll" aria-labelledby="people-h">
|
|
37
|
-
<h2 id="people-h" class="scroll__h">Builder directory<span class="count-pill" id="people-count" hidden></span></h2>
|
|
38
|
-
<div id="people-filters"></div>
|
|
39
|
-
<div id="people-body">
|
|
40
|
-
<div class="profile__placeholder">Loading the directory…</div>
|
|
41
|
-
</div>
|
|
42
|
-
<!-- task 1003110: the clipped-directory footnote. Hidden unless the
|
|
43
|
-
response came back FULL, which is the only truncation signal the
|
|
44
|
-
rollup offers; people.js writes it. -->
|
|
45
|
-
<p class="ledger__note" id="people-foot" hidden></p>
|
|
46
|
-
</section>
|
|
47
|
-
|
|
48
|
-
<!-- task 1002291 (ADR 0334): builders who hid their cross-project record
|
|
49
|
-
but are still open to recruiting, listed by name alone (privacy spec
|
|
50
|
-
D2). Its own panel because they are never ranked; hidden unless the
|
|
51
|
-
response carries a row, and people.js writes it. -->
|
|
52
|
-
<section class="scroll" id="people-named-scroll" aria-labelledby="people-named-h" hidden>
|
|
53
|
-
<h2 id="people-named-h" class="scroll__h">Listed by name only<span class="count-pill" id="people-named-count" hidden></span></h2>
|
|
54
|
-
<p class="scroll__lede" id="people-named-lede">These builders are open to recruiting but keep their cross-project record hidden, so only who they are is shown. They are never ranked, and a role filter never finds them.</p>
|
|
55
|
-
<div id="people-named-body"></div>
|
|
56
|
-
<p class="ledger__note" id="people-named-foot" hidden></p>
|
|
57
|
-
</section>
|
|
58
|
-
</main>
|
|
59
|
-
|
|
60
|
-
<footer class="app-foot" id="app-foot"></footer>
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
</div>
|
|
64
|
-
|
|
65
|
-
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
66
|
-
|
|
67
|
-
<!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
|
|
68
|
-
chrome exists before the page script runs. -->
|
|
69
|
-
<script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
|
|
70
|
-
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
71
|
-
<script src="/builders/palette.js" defer></script>
|
|
72
|
-
<!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
|
|
73
|
-
branding pack's currency label, loaded BEFORE people.js. -->
|
|
74
|
-
<script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
|
|
75
|
-
<script src="/builders/brand-apply.js"></script>
|
|
76
|
-
<script src="/builders/hall-kit.js"></script>
|
|
77
|
-
<script src="/builders/people.js"></script>
|
|
78
|
-
</body>
|
|
79
|
-
</html>
|