@bongos/core 1.19.1045 → 1.19.1047
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 +90 -115
- 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 +17 -24
- package/docs/copy-registry.json +24 -97
- package/docs/file-map.md +2 -3
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/core-release-pipeline.md +19 -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/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.html +1 -1
- 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 +1 -1
- 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/scripts/gds/go-live.js +101 -5
- 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/core_upgrade_owner_door.mjs +1 -1
- package/tests/edit_hints.mjs +8 -8
- package/tests/effective_visibility_predicate.mjs +1 -1
- package/tests/go_live.mjs +143 -4
- package/tests/government_parity.mjs +1 -1
- package/tests/hall_builders_page_pass.mjs +2 -2
- 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
package/tests/go_live.mjs
CHANGED
|
@@ -595,15 +595,22 @@ const phaseC = ({ timer = true, pin = '', version = '1.19.306', installed = '1.1
|
|
|
595
595
|
'##END##',
|
|
596
596
|
].filter(Boolean).join('\n');
|
|
597
597
|
|
|
598
|
-
// Records every script fed to a shell. `upgradeFails` drives the rollback path
|
|
599
|
-
|
|
598
|
+
// Records every script fed to a shell. `upgradeFails` drives the rollback path;
|
|
599
|
+
// `rehearsalRefuses` makes the --dry-run pre-flight say no (task 1004306).
|
|
600
|
+
function fakeShell({ upgradeFails = false, rehearsalRefuses = false, backupFails = false, pruned = '0', phaseCOut = phaseC() } = {}) {
|
|
600
601
|
const scripts = [];
|
|
601
602
|
const fn = (cmd, args, opts = {}) => {
|
|
602
603
|
const input = opts.input || '';
|
|
603
604
|
scripts.push(input);
|
|
605
|
+
if (backupFails && input.includes('##BACKUP##')) return { status: 1, stdout: '##TIMER##\n##BACKUP##\n', stderr: 'pg_dump: error: connection to server failed' };
|
|
606
|
+
if (input.includes('bongos upgrade') && input.includes('--dry-run')) {
|
|
607
|
+
return rehearsalRefuses
|
|
608
|
+
? { status: 1, stdout: '', stderr: "\n✖ 5 unresolved artist reviews hold this deploy — the project runs artist_gate 'strict'\n" }
|
|
609
|
+
: { status: 0, stdout: ' [dry-run] would pin', stderr: '' };
|
|
610
|
+
}
|
|
604
611
|
if (input.includes('bongos upgrade')) return { status: upgradeFails ? 1 : 0, stdout: '', stderr: upgradeFails ? 'boom' : '' };
|
|
605
612
|
if (input.includes('##PIN##')) return { status: 0, stdout: phaseCOut, stderr: '' };
|
|
606
|
-
return { status: 0, stdout:
|
|
613
|
+
return { status: 0, stdout: `##TIMER##\n##BACKUP##\n${input.includes('##PRUNE##') ? `##PRUNE##\n${pruned}\n` : ''}##END##`, stderr: '' };
|
|
607
614
|
};
|
|
608
615
|
fn.scripts = scripts;
|
|
609
616
|
fn.all = () => scripts.join('\n');
|
|
@@ -660,7 +667,7 @@ test('with no deployTimer, nothing is stopped, started, or reported as restarted
|
|
|
660
667
|
assert.ok(!/systemctl (stop|start)/.test(run.all()), 'must not touch a timer it has not got');
|
|
661
668
|
assert.ok(!run.all().includes('undefined'), 'no field may interpolate as undefined');
|
|
662
669
|
assert.ok(!stepNames(r).some((n) => /restart/.test(n)), 'no restart step for a timer that does not exist');
|
|
663
|
-
assert.match(stepNames(r)[
|
|
670
|
+
assert.match(stepNames(r)[1], /^backup \(no pull-deploy timer\)$/);
|
|
664
671
|
});
|
|
665
672
|
|
|
666
673
|
test('the platform instance still stops the timer and puts it back', () => {
|
|
@@ -728,3 +735,135 @@ test('the shipped co-tenant template is itself valid config', () => {
|
|
|
728
735
|
assert.equal(sel.target.pinMode, 'local-commit');
|
|
729
736
|
assert.equal(sel.target.deployTimer, undefined);
|
|
730
737
|
});
|
|
738
|
+
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
// Refuse before you copy; keep ten (task 1004306)
|
|
741
|
+
//
|
|
742
|
+
// On cloudbongos.com, 2026-09-26, the artist gate refused two upgrades at 17:10 and
|
|
743
|
+
// 17:15 UTC — and each had already stopped the deploy timer and written a 65 MB
|
|
744
|
+
// pg_dump, because the backup was Phase A and the refusal lived inside Phase B.
|
|
745
|
+
// Nothing pruned the directory (37 dumps, 1.7 GB, at about one attempt a day). At a
|
|
746
|
+
// fifteen-minute cadence that fills the disk in about three days.
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
|
|
749
|
+
const withBackup = { ...okTarget, backupDb: 'instdb', backupDir: '~/backups', runAs: 'me' };
|
|
750
|
+
|
|
751
|
+
test('a refused rehearsal stops the go-live before any timer is stopped or any dump is written', () => {
|
|
752
|
+
const run = fakeShell({ rehearsalRefuses: true });
|
|
753
|
+
const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
|
|
754
|
+
|
|
755
|
+
assert.equal(r.ok, false);
|
|
756
|
+
assert.equal(r.failedAt, 'PREFLIGHT');
|
|
757
|
+
assert.equal(r.refused, true);
|
|
758
|
+
assert.equal(run.scripts.length, 1, 'the rehearsal is the only thing that may run');
|
|
759
|
+
assert.ok(!/pg_dump/.test(run.all()), 'no backup may be written for a refused move');
|
|
760
|
+
assert.ok(!/systemctl stop/.test(run.all()), 'the deploy timer must be left running');
|
|
761
|
+
assert.deepEqual(stepNames(r), ['pre-flight (the upgrade, rehearsed with --dry-run)']);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('the rehearsal is the REAL upgrade command plus --dry-run, and it runs first', () => {
|
|
765
|
+
const run = fakeShell();
|
|
766
|
+
const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
|
|
767
|
+
assert.equal(r.ok, true, `expected a clean run, got ${JSON.stringify(r.steps)}`);
|
|
768
|
+
|
|
769
|
+
// Same flags as the bump, so it asks exactly the gates the bump will ask — a
|
|
770
|
+
// rehearsal of a different command would be a check of something else.
|
|
771
|
+
assert.ok(run.scripts[0].includes(`${G.upgradeCommand(withBackup, '1.19.306')} --dry-run`));
|
|
772
|
+
const order = (needle) => run.scripts.findIndex((s) => s.includes(needle));
|
|
773
|
+
assert.ok(order('--dry-run') < order('pg_dump'), 'rehearse before the backup');
|
|
774
|
+
assert.ok(order('--dry-run') < order('systemctl stop'), 'rehearse before the timer stops');
|
|
775
|
+
assert.ok(order('pg_dump') < run.scripts.findIndex((s) => s.includes('bongos upgrade') && !s.includes('--dry-run')), 'the backup still precedes the real bump');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('a refusal renders as "nothing changed", never as an unpushed pin', () => {
|
|
779
|
+
const run = fakeShell({ rehearsalRefuses: true });
|
|
780
|
+
const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
|
|
781
|
+
const out = G.renderReport(r);
|
|
782
|
+
assert.match(out, /Nothing changed — still on 1\.19\.300\. No timer was stopped and no backup was written\./);
|
|
783
|
+
assert.match(out, /5 unresolved artist reviews hold this deploy/, "the upgrade's own reason is shown");
|
|
784
|
+
assert.ok(!/pin committed/.test(out), 'no pin line for a bump that never started');
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test('a successful dump is followed by a prune, and the step says what it removed', () => {
|
|
788
|
+
const run = fakeShell({ pruned: '3' });
|
|
789
|
+
const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
|
|
790
|
+
assert.equal(r.ok, true);
|
|
791
|
+
const phaseA = run.scripts.find((s) => s.includes('##BACKUP##'));
|
|
792
|
+
assert.ok(phaseA.indexOf('##PRUNE##') > phaseA.indexOf('pg_dump'), 'prune only after the dump');
|
|
793
|
+
const step = r.steps.find((s) => /prune/.test(s.name));
|
|
794
|
+
assert.equal(step.name, `prune old pre-upgrade backups (keep the newest ${G.PRE_UPGRADE_KEEP})`);
|
|
795
|
+
assert.equal(step.detail, 'removed 3');
|
|
796
|
+
assert.equal(G.PRE_UPGRADE_KEEP, 10);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test('no backupDb, no dump, and no prune', () => {
|
|
800
|
+
const run = fakeShell();
|
|
801
|
+
const r = G.applyGoLive(okTarget, 'web', '1.19.306', '1.19.300', { run });
|
|
802
|
+
assert.equal(r.ok, true);
|
|
803
|
+
assert.ok(!/##PRUNE##/.test(run.all()));
|
|
804
|
+
assert.ok(!r.steps.some((s) => /prune/.test(s.name)));
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test('a prune that cannot run warns and does not fail the deploy', () => {
|
|
808
|
+
const step = G.pruneStep('prune exited non-zero');
|
|
809
|
+
assert.equal(step.ok, true, 'the dump is good; tidiness must not cost the deploy');
|
|
810
|
+
assert.equal(step.warn, true);
|
|
811
|
+
assert.match(step.detail, /could not prune \(prune exited non-zero\)/);
|
|
812
|
+
assert.equal(G.pruneStep(null).warn, true, 'a missing section is not "nothing to remove"');
|
|
813
|
+
assert.equal(G.pruneStep('0').detail, 'nothing to remove');
|
|
814
|
+
assert.match(G.renderReport({ targetName: 'x', from: 'a', to: 'b', ok: true, steps: [step] }), /⚠ prune old pre-upgrade backups/);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test('the dump fails when pg_dump fails, and a failed dump puts the stopped timer back', () => {
|
|
818
|
+
// The composed command: pipefail inside, and the partial file removed on failure.
|
|
819
|
+
const run = fakeShell();
|
|
820
|
+
G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
|
|
821
|
+
const phaseA = run.scripts.find((s) => s.includes('##BACKUP##'));
|
|
822
|
+
assert.ok(phaseA.includes('bash -c "set -o pipefail; mkdir -p ~/backups && { pg_dump instdb | gzip > ~/backups/pre-1.19.306-$TS.sql.gz || { rm -f ~/backups/pre-1.19.306-$TS.sql.gz; exit 1; }; }"'), phaseA);
|
|
823
|
+
assert.match(phaseA, /^TS="\$\(date -u \+%Y%m%dT%H%M%SZ\)"$/m, 'one timestamp for the dump and its cleanup');
|
|
824
|
+
|
|
825
|
+
const failing = fakeShell({ backupFails: true });
|
|
826
|
+
const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run: failing });
|
|
827
|
+
assert.equal(r.ok, false);
|
|
828
|
+
assert.equal(r.failedAt, 'PREPARE');
|
|
829
|
+
assert.ok(!failing.scripts.some((s) => s.includes('bongos upgrade') && !s.includes('--dry-run')), 'no bump without a backup');
|
|
830
|
+
assert.match(failing.scripts[failing.scripts.length - 1], /systemctl start inst-deploy\.timer/, 'the stopped timer is restarted');
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
// The prune command itself, run for real in a scratch directory. The composed-string
|
|
834
|
+
// tests above cannot tell a glob that matches nothing from one that matches too much.
|
|
835
|
+
test('the prune keeps the newest ten by time, and leaves every other file alone', { skip: process.platform === 'win32' && 'needs a POSIX shell' }, async () => {
|
|
836
|
+
const { spawnSync } = await import('node:child_process');
|
|
837
|
+
const os = await import('node:os');
|
|
838
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'golive-prune-'));
|
|
839
|
+
try {
|
|
840
|
+
const dumps = [];
|
|
841
|
+
// 13 dumps, oldest first, whose NAME order disagrees with their TIME order.
|
|
842
|
+
for (let i = 0; i < 13; i++) {
|
|
843
|
+
const name = `pre-1.19.${i % 2 ? 99 - i : 100 + i}-202609${String(10 + i).padStart(2, '0')}T000000Z.sql.gz`;
|
|
844
|
+
const f = path.join(dir, name);
|
|
845
|
+
fs.writeFileSync(f, 'x');
|
|
846
|
+
const t = Date.UTC(2026, 8, 10 + i) / 1000;
|
|
847
|
+
fs.utimesSync(f, t, t);
|
|
848
|
+
dumps.push(name);
|
|
849
|
+
}
|
|
850
|
+
const others = ['pre-lane-sweep-20260823T203810Z.sql.gz', 'cloudbongos-2026-09-26T040000Z.sql.gz', 'notes.txt'];
|
|
851
|
+
for (const o of others) {
|
|
852
|
+
fs.writeFileSync(path.join(dir, o), 'x');
|
|
853
|
+
fs.utimesSync(path.join(dir, o), 1, 1); // older than every dump
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const res = spawnSync('sh', ['-c', G.pruneCommand(dir)], { encoding: 'utf8' });
|
|
857
|
+
assert.equal(res.status, 0, res.stderr);
|
|
858
|
+
assert.equal(res.stdout.trim(), '3');
|
|
859
|
+
const left = new Set(fs.readdirSync(dir));
|
|
860
|
+
for (const d of dumps.slice(0, 3)) assert.ok(!left.has(d), `${d} is one of the three oldest and must go`);
|
|
861
|
+
for (const d of dumps.slice(3)) assert.ok(left.has(d), `${d} is one of the newest ten and must stay`);
|
|
862
|
+
for (const o of others) assert.ok(left.has(o), `${o} is not a pre-upgrade dump and must never be pruned`);
|
|
863
|
+
|
|
864
|
+
// A second run has nothing left to do.
|
|
865
|
+
assert.equal(spawnSync('sh', ['-c', G.pruneCommand(dir)], { encoding: 'utf8' }).stdout.trim(), '0');
|
|
866
|
+
} finally {
|
|
867
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
868
|
+
}
|
|
869
|
+
});
|
|
@@ -280,7 +280,7 @@ function parsePageGates(src) {
|
|
|
280
280
|
// Each entry's own object literal is bounded by [^}]* so the scan cannot run past a
|
|
281
281
|
// gate that lacks a `permission` key and silently adopt the NEXT gate's. `permission`
|
|
282
282
|
// is then found ANYWHERE inside that literal rather than assumed to come first —
|
|
283
|
-
//
|
|
283
|
+
// every gate carries a `deniedTo` beside it, and key order in an object
|
|
284
284
|
// literal is not a contract anyone maintains.
|
|
285
285
|
for (const m of block[1].matchAll(/(require[A-Za-z]*Page):\s*Object\.freeze\(\{([^}]*)\}\)/g)) {
|
|
286
286
|
const perm = m[2].match(/permission:\s*'([^']+)'/);
|
|
@@ -223,9 +223,9 @@ test('B: a payload with no id degrades to the place alone', async () => {
|
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
test('B: the id wears the same mono/faint voice as the login', () => {
|
|
226
|
-
const css = read('
|
|
226
|
+
const css = read('roster.css');
|
|
227
227
|
const i = css.indexOf('.ledger__place .ledger__id {');
|
|
228
|
-
assert.ok(i > 0, '
|
|
228
|
+
assert.ok(i > 0, 'roster.css styles the roster id');
|
|
229
229
|
const block = css.slice(i, css.indexOf('}', i));
|
|
230
230
|
for (const token of ['var(--font-mono)', 'var(--ink-faint)']) {
|
|
231
231
|
assert.ok(block.includes(token), `the id spends ${token}, the voice .dir-login already uses`);
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -266,10 +266,9 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
266
266
|
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'ideas', 'thinking', 'copy-desk', 'studio'],
|
|
267
267
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
268
268
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
// was stricter than the wall). requireProjectCuratePage is the wall server-side.
|
|
269
|
+
// task 1003444: 'people' LEFT the group — the owner deleted the hall's
|
|
270
|
+
// cross-project directory (ADR 0337 D5); its home is the hub, and /people
|
|
271
|
+
// redirects to /roster. Builders is the hall's directory now.
|
|
273
272
|
// task 1003172 (C02 of goal 1000075): 'collab' joins Community, FIRST. The
|
|
274
273
|
// group is picked by whose data it is, and a help request is another builder
|
|
275
274
|
// reaching you — not your own numbers (that is You), and not reference (Docs).
|
|
@@ -280,7 +279,8 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
280
279
|
// changed: it was a hash view on index.html (href /#leaderboard, view:
|
|
281
280
|
// 'leaderboard') and is now a real page at /roster labelled Builders. That is
|
|
282
281
|
// what made this group read as one real page plus a link into the home page.
|
|
283
|
-
assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster'
|
|
282
|
+
assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster'], 'Community = everyone else, in this project');
|
|
283
|
+
assert.ok(!nav.flatMap((g) => g.items).some((i) => /^\/people\b/.test(i.href)), 'no nav item points at the deleted /people');
|
|
284
284
|
assert.deepEqual(nav[3].items.map((i) => i.id),
|
|
285
285
|
['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
|
|
286
286
|
// 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
|
|
@@ -343,11 +343,11 @@ test('THE CORRECTION: Community shows a xenos two items, Collab and Builders', (
|
|
|
343
343
|
test('neither Community page hides behind a rank the page itself does not enforce', () => {
|
|
344
344
|
// The invariant this task exists to restore: a nav flag may be narrower than
|
|
345
345
|
// the wall for cosmetic reasons (/profile does it), but it must never assert a
|
|
346
|
-
// RANK the page gate does not. /people
|
|
347
|
-
//
|
|
346
|
+
// RANK the page gate does not. /people was the one item with a legitimate rank
|
|
347
|
+
// flag; it left the hall in task 1003444, so the group now carries none.
|
|
348
348
|
const community = parseNav(boot().sidebarHtml).find((g) => g.label === 'Community');
|
|
349
349
|
const ranked = community.items.filter((i) => RANK_GATES.has(i.gate)).map((i) => i.id);
|
|
350
|
-
assert.deepEqual(ranked, [
|
|
350
|
+
assert.deepEqual(ranked, [], 'no Community item carries a rank flag');
|
|
351
351
|
});
|
|
352
352
|
|
|
353
353
|
test('no group head is gated wider than its own items', () => {
|
|
@@ -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);
|