@bongos/core 1.19.622 → 1.19.623

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.622",
3
+ "version": "1.19.623",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -59,6 +59,13 @@ const VOCAB_SURFACE = [
59
59
  'modules/hall-ui/public/government.js',
60
60
  'modules/hall-ui/public/government.html',
61
61
  'modules/hall-ui/public/government.css',
62
+ // ADR 0266 carved the Board Room out of government.js onto its own page. The
63
+ // wall follows the SURFACE, not the filename — without these two rows the
64
+ // room's own files would have left the wall's scope, which is how a renamed
65
+ // file quietly drops an enforcement it used to be held to.
66
+ 'modules/hall-ui/public/board-room.js',
67
+ 'modules/hall-ui/public/board-room.html',
68
+ 'modules/hall-ui/public/board-room.css',
62
69
  ];
63
70
  const VOCAB_EXTS = new Set(['.js', '.json', '.html', '.css']);
64
71
  const VOCAB_BANNED = [
@@ -401,6 +401,21 @@ const PAGE_GATES = Object.freeze({
401
401
  requireProjectCuratePage: Object.freeze({
402
402
  permission: 'project.curate', deniedTo: '/builders', alsoAdmits: 'isProjectOwner',
403
403
  }),
404
+ // The /board-room page (ADR 0266) — gated on `board.vote.cast`, the SAME atom
405
+ // the vote route checks, so the surface's reach equals the right to vote.
406
+ //
407
+ // That atom floors at XENOS on purpose (modules/government/board.js — low so
408
+ // that WIDENING the board works), which makes this the widest page gate in the
409
+ // table. It is deliberate and it is not a hole: the floor is the coarse gate,
410
+ // and MEMBERSHIP is still checked in-handler per item against that item's own
411
+ // snapshotted constitution. A holder who sits on no board sees the room and no
412
+ // ballot — the honest state, since ADR 0175 §9 made the ballot open on purpose.
413
+ //
414
+ // Reaching for `page.view.government` (metic+) instead would look tighter and
415
+ // be wrong: an instance that widens membership below Metic would then have
416
+ // members who can cast a vote through the API and cannot load the page they
417
+ // would cast it on. That is the code-path change ADR 0175 §6 exists to remove.
418
+ requireBoardVotePage: Object.freeze({ permission: 'board.vote.cast', deniedTo: '/builders' }),
404
419
  });
405
420
 
406
421
  // The `alsoAdmits` door — "is this visitor the page's OTHER audience?", asked of
@@ -550,6 +565,17 @@ async function requireProjectCuratePage(req, res, next) {
550
565
  return runPageGate('requireProjectCuratePage', req, res, next);
551
566
  }
552
567
 
568
+ // The /board-room page (ADR 0266) — gated on the `board.vote.cast` ATOM, the
569
+ // same key the vote route checks, so the page layer can never drift NARROWER
570
+ // than the API it fronts. That is the direction that matters here: the Board
571
+ // Room used to live inside /government behind `page.view.government` (metic+)
572
+ // while the vote atom floors at xenos, so a widened board produced members who
573
+ // could vote by API and not load the page. See PAGE_GATES above for why the
574
+ // wide floor is correct rather than a hole (membership is the in-handler check).
575
+ async function requireBoardVotePage(req, res, next) {
576
+ return runPageGate('requireBoardVotePage', req, res, next);
577
+ }
578
+
553
579
  // The orientation reading rooms (the citizen's primer, the system-maps/diagrams)
554
580
  // that #766 closed to newcomers and #770 enforces at the URL. A Xenos's path is
555
581
  // action-only — find work, claim, ship three — so these pages are reserved for
@@ -821,6 +847,7 @@ module.exports = {
821
847
  requireGovernmentManagePage,
822
848
  requireNonXenosPage,
823
849
  requireProjectCuratePage,
850
+ requireBoardVotePage,
824
851
  requireRank,
825
852
  rankMeetsThreshold,
826
853
  requirePermission,
@@ -844,6 +844,21 @@ function mountInternalSurfaces(app) {
844
844
  // the `government.manage` atom itself (archon), the same key its data routes
845
845
  // check — requireGovernmentManagePage, not the metic-floored gate above.
846
846
  const GOVERNMENT_PAGE_RE = /^\/builders\/government(?:\.html)?\/?$/;
847
+ // /board-room — the Board Room, carved out of the /government tab strip onto
848
+ // its own page (ADR 0266). It does NOT take the gate above. Its atom is
849
+ // `board.vote.cast`, the same key the vote route checks, so the page's reach
850
+ // equals the right to vote: that atom floors at xenos on purpose, so that a
851
+ // constitution widening membership below Metic gets members who can actually
852
+ // reach the room rather than members who can only vote by curl. Membership is
853
+ // still the in-handler check, per item, against that item's own snapshotted
854
+ // constitution.
855
+ //
856
+ // NB as at PROJECT_SETTINGS_PAGE_RE: this assignment must stay
857
+ // `const NAME_RE = /…/;` with nothing between the `=` and the literal —
858
+ // tests/hall_page_gate_map.mjs rebuilds the URL→gate map by parsing this file
859
+ // statically, and a comment in that gap drops the regex out of the map, which
860
+ // makes the page test as UNGATED while looking gated here. Prose goes above.
861
+ const BOARD_ROOM_PAGE_RE = /^\/builders\/board-room(?:\.html)?\/?$/;
847
862
  // The /people builder directory (task 1002577): gated on `project.curate`, the
848
863
  // exact permission its data route (GET /scouting) checks — same posture as the
849
864
  // /government tab above (the page can never drift wider than the API it fronts).
@@ -885,6 +900,12 @@ function mountInternalSurfaces(app) {
885
900
  if (GOVERNMENT_PAGE_RE.test(norm)) {
886
901
  return gdsAuth.requireGovernmentPage(req, res, next);
887
902
  }
903
+ // ADR 0266: the Board Room's own page, on the vote atom rather than the
904
+ // government page floor. Ordered after the branch above only for reading
905
+ // order — the regexes are disjoint, so the sequence carries no meaning.
906
+ if (BOARD_ROOM_PAGE_RE.test(norm)) {
907
+ return gdsAuth.requireBoardVotePage(req, res, next);
908
+ }
888
909
  if (PEOPLE_PAGE_RE.test(norm)) {
889
910
  return gdsAuth.requireProjectCuratePage(req, res, next);
890
911
  }
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.622'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.623'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -94,16 +94,17 @@ test('AUDIT: every async middleware mounted directly on a router owns its reject
94
94
  // requireBuilder — the API gate. Guarded here (BV1.R109-follow-up).
95
95
  // requireBuilderPage \
96
96
  // requireGovernmentPage \
97
- // requireGovernmentManagePage > all five delegate to runPageGate, guarded by BV1.R107.
98
- // requireNonXenosPage / (the R113 roles-tab gate; the /people curate gate)
99
- // requireProjectCuratePage /
97
+ // requireGovernmentManagePage \ all SIX delegate to runPageGate, guarded by BV1.R107.
98
+ // requireNonXenosPage / (the R113 roles-tab gate; the /people curate
99
+ // requireProjectCuratePage / gate; the ADR 0266 /board-room vote gate)
100
+ // requireBoardVotePage /
100
101
  // Anything else async and router-mounted must be added with its own try/catch.
101
102
  const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
102
103
  const asyncMw = [...src.matchAll(/^async function (require\w+|allow\w+)\s*\(req, res, next\)/gm)].map((m) => m[1]);
103
- assert.deepEqual(asyncMw.sort(), ['requireBuilder', 'requireBuilderPage', 'requireGovernmentManagePage', 'requireGovernmentPage', 'requireNonXenosPage', 'requireProjectCuratePage'],
104
+ assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireGovernmentManagePage', 'requireGovernmentPage', 'requireNonXenosPage', 'requireProjectCuratePage'],
104
105
  'a new async router middleware appeared — give it a try/catch and add it here');
105
106
  // The page gates must actually delegate (that is WHY they are safe).
106
- for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireGovernmentManagePage', 'requireNonXenosPage', 'requireProjectCuratePage']) {
107
+ for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireGovernmentManagePage', 'requireNonXenosPage', 'requireProjectCuratePage', 'requireBoardVotePage']) {
107
108
  const fn = src.slice(src.indexOf(`async function ${name}`));
108
109
  assert.match(sliceFn(fn), /runPageGate\(/, `${name} must delegate to the guarded runPageGate`);
109
110
  }
@@ -0,0 +1,129 @@
1
+ // tests/government_board_hash_redirect.mjs — the `#board-room` compatibility
2
+ // redirect (ADR 0266, task 1003735).
3
+ //
4
+ // WHY THIS FILE EXISTS. ADR 0266 carved the Board Room out of the /government
5
+ // tab strip onto its own page, and named this redirect load-bearing for a reason
6
+ // that does not expire: Discord's window-open announcement deep-links
7
+ // `?item=<id>#board-room` (ADR 0175 §8 makes that mention the "vote within X"
8
+ // notice), and every one of those messages already sitting in channel history
9
+ // carries the old form forever. If the redirect breaks, a member clicking the
10
+ // notification lands on the Permissions page with no way to the sitting.
11
+ //
12
+ // AND WHY IT CANNOT BE A SERVER TEST. A URL fragment is never sent to the
13
+ // server, so `#board-room` cannot be caught by a route in serve-internal.js —
14
+ // adding a regex there looks like the obvious fix and silently never fires. That
15
+ // makes this browser function the ONLY thing standing between an old link and a
16
+ // dead end, which is exactly the shape that deserves a pin.
17
+ //
18
+ // It is extracted and RUN, not grepped: the interesting behaviour is the
19
+ // branch (which hashes trigger) and the query-preservation (encodeURIComponent
20
+ // on a value that came off the URL), and a source match would pass on a
21
+ // function that returns the wrong string.
22
+ //
23
+ // Pure file-read + vm (no DB/network) — runs in the DB-free unit gate.
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import vm from 'node:vm';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
33
+ const GOV_SRC = fs.readFileSync(path.join(HALL, 'government.js'), 'utf8');
34
+
35
+ // Lift the function out of the page's IIFE and run it against a fake location.
36
+ // `sliceFn` counts braces rather than matching a closing line, so a nested block
37
+ // inside the body cannot end the slice early.
38
+ function sliceFn(src, name) {
39
+ const start = src.indexOf(`function ${name}`);
40
+ assert.notEqual(start, -1, `${name} not found in government.js`);
41
+ const open = src.indexOf('{', start);
42
+ let depth = 0;
43
+ for (let i = open; i < src.length; i += 1) {
44
+ if (src[i] === '{') depth += 1;
45
+ else if (src[i] === '}') {
46
+ depth -= 1;
47
+ if (depth === 0) return src.slice(start, i + 1);
48
+ }
49
+ }
50
+ throw new Error(`${name} is unbalanced`);
51
+ }
52
+
53
+ // Returns { result, replaced } — `replaced` is the URL location.replace was
54
+ // called with, or null if it was never called.
55
+ function runRedirect({ hash, search = '' }) {
56
+ let replaced = null;
57
+ const sandbox = {
58
+ URLSearchParams,
59
+ window: {
60
+ location: {
61
+ hash,
62
+ search,
63
+ replace(url) { replaced = url; },
64
+ },
65
+ },
66
+ };
67
+ vm.createContext(sandbox);
68
+ vm.runInContext(`${sliceFn(GOV_SRC, 'redirectLegacyBoardHash')}
69
+ globalThis.__out = redirectLegacyBoardHash();`, sandbox, { filename: 'government.js' });
70
+ return { result: sandbox.__out, replaced };
71
+ }
72
+
73
+ test('the old hash lands on the room, and says it navigated', () => {
74
+ const { result, replaced } = runRedirect({ hash: '#board-room' });
75
+ assert.equal(replaced, '/board-room');
76
+ assert.equal(result, true,
77
+ 'init() returns early on true — a falsy answer would let the government page paint over the navigation');
78
+ });
79
+
80
+ test('?item=N is carried across — that is the whole point of the Discord link', () => {
81
+ const { replaced } = runRedirect({ hash: '#board-room', search: '?item=12' });
82
+ assert.equal(replaced, '/board-room?item=12');
83
+ });
84
+
85
+ test('the item is re-encoded, never pasted through raw', () => {
86
+ // The value arrives off a URL a stranger can author, and goes straight back
87
+ // into one. Board item ids are numeric, so this is defence rather than a live
88
+ // case — which is why it is pinned instead of assumed.
89
+ const { replaced } = runRedirect({ hash: '#board-room', search: '?item=1%202%263' });
90
+ assert.equal(replaced, '/board-room?item=1%202%263',
91
+ 'a space and an ampersand survive as escapes rather than splitting the query');
92
+ });
93
+
94
+ test('an item that is absent or empty produces a bare URL, not a dangling ?item=', () => {
95
+ assert.equal(runRedirect({ hash: '#board-room', search: '?other=1' }).replaced, '/board-room');
96
+ assert.equal(runRedirect({ hash: '#board-room', search: '?item=' }).replaced, '/board-room',
97
+ 'an empty value is falsy, so no query is appended');
98
+ });
99
+
100
+ test('every OTHER hash is left alone — the two surviving tabs still work', () => {
101
+ // The government page kept #constitution and #ranks. A redirect that fired on
102
+ // those would make the page unreachable, which is a worse failure than the one
103
+ // this function exists to prevent.
104
+ for (const hash of ['#constitution', '#ranks', '', '#board-room-extra', '#Board-Room']) {
105
+ const { result, replaced } = runRedirect({ hash, search: '?item=12' });
106
+ assert.equal(replaced, null, `${hash || '(no hash)'} must not navigate`);
107
+ assert.equal(result, false, `${hash || '(no hash)'} must let init() continue`);
108
+ }
109
+ });
110
+
111
+ test('init() consults it FIRST and returns on true', () => {
112
+ // The order is the behaviour: called after the /me fetch, the reader would see
113
+ // the government page paint and then jump. Asserted on the source because it is
114
+ // a statement about sequence, not a value.
115
+ const init = sliceFn(GOV_SRC, 'init');
116
+ const guard = init.indexOf('redirectLegacyBoardHash()');
117
+ assert.notEqual(guard, -1, 'init() must consult the redirect');
118
+ assert.match(init.slice(guard - 30, guard + 60), /if \(redirectLegacyBoardHash\(\)\) return;/,
119
+ 'it must be an early return, not a fire-and-continue');
120
+ const meFetch = init.indexOf("fetchJson('/me')");
121
+ assert.ok(guard < meFetch, 'the redirect must run BEFORE the /me read and any paint');
122
+ });
123
+
124
+ test('the government page no longer declares a board-room tab to redirect INTO', () => {
125
+ // If #board-room came back as a tab id, this redirect would fight makeTabs for
126
+ // the same hash and the room would exist in two places.
127
+ const ids = [...GOV_SRC.matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
128
+ assert.ok(!ids.includes('board-room'), `board-room is a page, not a tab (found ${ids})`);
129
+ });
@@ -26,20 +26,28 @@ import { fileURLToPath } from 'node:url';
26
26
 
27
27
  const require = createRequire(import.meta.url);
28
28
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ // TWO SOURCES SINCE ADR 0266. The Board Room was the `#board-room` tab of the
30
+ // government page and is now its own page, so the sitting card, its copy and the
31
+ // vote form live in board-room.js / board-room.html. `CLIENT` and `HTML` stay
32
+ // pointed at the government page for the two assertions that are genuinely about
33
+ // what STAYED there — the Constitution's own membership sentence, and the short
34
+ // pass-rule name its change-history line reads.
29
35
  const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
30
36
  const HTML = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.html'), 'utf8');
37
+ const BOARD = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'board-room.js'), 'utf8');
38
+ const BOARD_HTML = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'board-room.html'), 'utf8');
31
39
  const { PASS_RULES } = require(path.join(ROOT, 'modules', 'government', 'config.js'));
32
40
 
33
- const card = () => CLIENT.slice(CLIENT.indexOf('function itemCard'), CLIENT.indexOf('async function loadBoard'));
41
+ const card = () => BOARD.slice(BOARD.indexOf('function itemCard'), BOARD.indexOf('async function loadBoard'));
34
42
 
35
43
  // Deleted copy is asserted against the RENDERING source only. The comments in
36
- // government.js deliberately quote what was removed and why — that history is
44
+ // board-room.js deliberately quote what was removed and why — that history is
37
45
  // the point of them, and a test that forbade it would push the record out of
38
46
  // the file it explains.
39
- const CODE = CLIENT.split(/\r?\n/).filter((l) => !/^\s*\/\//.test(l)).join('\n');
47
+ const CODE = BOARD.split(/\r?\n/).filter((l) => !/^\s*\/\//.test(l)).join('\n');
40
48
 
41
49
  test('every pass rule the server can hold has a plain-language sentence', () => {
42
- const block = CLIENT.slice(CLIENT.indexOf('const PASS_RULE_PLAIN'), CLIENT.indexOf('function decidedByHtml'));
50
+ const block = BOARD.slice(BOARD.indexOf('const PASS_RULE_PLAIN'), BOARD.indexOf('function decidedByHtml'));
43
51
  assert.ok(PASS_RULES.length >= 3, `sanity: found the real rule set (${PASS_RULES})`);
44
52
  for (const rule of PASS_RULES) {
45
53
  assert.ok(block.includes(`${rule}:`), `PASS_RULE_PLAIN must explain '${rule}' — a rule with no copy renders its enum key`);
@@ -52,14 +60,23 @@ test('every pass rule the server can hold has a plain-language sentence', () =>
52
60
  });
53
61
 
54
62
  test('the short name map covers the same rule set (the history line)', () => {
55
- const block = CLIENT.slice(CLIENT.indexOf('const PASS_RULE_NAME'), CLIENT.indexOf('const PASS_RULE_PLAIN'));
63
+ // Its reader (historyRow, the amendment change-history) stayed on the
64
+ // government page when ADR 0266 carved the room out, so this map stayed with
65
+ // it. The slice therefore ends at a landmark that is still IN government.js —
66
+ // it used to end at `const PASS_RULE_PLAIN`, which has moved to board-room.js,
67
+ // and an indexOf that misses returns -1: `slice(start, -1)` would have handed
68
+ // this assertion most of the file and passed for the wrong reason.
69
+ const start = CLIENT.indexOf('const PASS_RULE_NAME');
70
+ const end = CLIENT.indexOf('function dialRow');
71
+ assert.ok(start !== -1 && end > start, 'PASS_RULE_NAME sits above dialRow in government.js');
72
+ const block = CLIENT.slice(start, end);
56
73
  for (const rule of PASS_RULES) {
57
74
  assert.ok(block.includes(`${rule}:`), `PASS_RULE_NAME must name '${rule}'`);
58
75
  }
59
76
  });
60
77
 
61
78
  test('a rank reaches the reader as a LABEL, never as the enum key', () => {
62
- assert.match(CLIENT, /function rankNames\(keys\)[\s\S]*?window\.OTB\.rankLabel\(k\)/,
79
+ assert.match(BOARD, /function rankNames\(keys\)[\s\S]*?window\.OTB\.rankLabel\(k\)/,
63
80
  'the board card resolves rank keys through the branding pack');
64
81
  const membership = CLIENT.slice(CLIENT.indexOf('function membershipSentence'), CLIENT.indexOf('function passRuleSentence'));
65
82
  assert.match(membership, /window\.OTB\.rankLabel\(k\)/,
@@ -84,7 +101,7 @@ test('the card order is title → action → the idea → the apparatus', () =>
84
101
  });
85
102
 
86
103
  test('the idea renders OPEN — never behind a summary', () => {
87
- const sections = CLIENT.slice(CLIENT.indexOf('function ideaSectionsHtml'), CLIENT.indexOf('function rankNames'));
104
+ const sections = BOARD.slice(BOARD.indexOf('function ideaSectionsHtml'), BOARD.indexOf('function rankNames'));
88
105
  assert.ok(!/<details/.test(sections),
89
106
  'the substance of the vote is not a disclosure — that was the reported bug');
90
107
  assert.match(sections, /gov-idea__body/, 'and it is set as prose, not as a config row');
@@ -99,12 +116,12 @@ test('the tally stays visible without a click (ADR 0175 §9)', () => {
99
116
  test('the self-explaining copy is DELETED, not reworded', () => {
100
117
  assert.ok(!/Casting arrives with the vote form/.test(CODE),
101
118
  'the sentence that explained the UI to itself — and the form it promised has shipped');
102
- assert.ok(!/tally: \$\{yes\} yes/.test(CLIENT), 'the raw-config tally line is gone');
119
+ assert.ok(!/tally: \$\{yes\} yes/.test(BOARD), 'the raw-config tally line is gone');
103
120
  assert.ok(!/The idea, in full/.test(CODE), 'so is the summary that hid the idea');
104
121
  });
105
122
 
106
123
  test('the section a voter faults is shown by NAME, in the ballot and the picker', () => {
107
- const ballot = CLIENT.slice(CLIENT.indexOf('function ballotHtml'), CLIENT.indexOf('// ---- the vote form'));
124
+ const ballot = BOARD.slice(BOARD.indexOf('function ballotHtml'), BOARD.indexOf('// ---- the vote form'));
108
125
  assert.match(ballot, /SECTION_LABELS\[v\.section_key\]/,
109
126
  '`why_matters` is a jsonb key; the objector picked a named section and the reader should see that name');
110
127
  });
@@ -112,18 +129,27 @@ test('the section a voter faults is shown by NAME, in the ballot and the picker'
112
129
  test('the objection still carries a reason AND one section (ADR 0175 §8)', () => {
113
130
  // The requirement simplifying may not weaken: it is why voting lives in the
114
131
  // hall instead of on a chat reaction.
115
- const form = CLIENT.slice(CLIENT.indexOf('function voteFormHtml'), CLIENT.indexOf('async function castVoteFromRoom'));
132
+ const form = BOARD.slice(BOARD.indexOf('function voteFormHtml'), BOARD.indexOf('async function castVoteFromRoom'));
116
133
  assert.match(form, /data-vote-reason/, 'the reason box survives');
117
134
  assert.match(form, /gov-vote__sections/, 'so does the five-way section picker');
118
135
  assert.match(form, /Object\.keys\(SECTION_LABELS\)\.map/, 'with one radio per declared section');
119
136
  });
120
137
 
121
- test('the page frames its panels in one line each, not a paragraph', () => {
138
+ test('each room frames itself in one line, not a paragraph', () => {
122
139
  // The two intros used to run ~60 and ~50 words before anything actionable.
123
- for (const heading of ['Constitution', 'Board Room']) {
124
- const i = HTML.indexOf(`<h2 class="gov-h">${heading}</h2>`);
125
- assert.notEqual(i, -1, `${heading} panel heading missing`);
126
- const note = /<p class="gov-grid-note">([\s\S]*?)<\/p>/.exec(HTML.slice(i));
140
+ //
141
+ // They now live on two pages (ADR 0266), and the Board Room's heading is its
142
+ // page's own <h1> rather than a panel <h2> — a second "Board Room" heading
143
+ // under the page head would be the copy-that-explains-the-UI-to-itself this
144
+ // file exists to keep out. So the landmark differs per room; the RULE does not.
145
+ const rooms = [
146
+ { heading: 'Constitution', src: HTML, at: '<h2 class="gov-h">Constitution</h2>' },
147
+ { heading: 'Board Room', src: BOARD_HTML, at: '<h1 class="page-head__h">Board Room</h1>' },
148
+ ];
149
+ for (const { heading, src, at } of rooms) {
150
+ const i = src.indexOf(at);
151
+ assert.notEqual(i, -1, `${heading} heading missing (${at})`);
152
+ const note = /<p class="gov-grid-note">([\s\S]*?)<\/p>/.exec(src.slice(i));
127
153
  assert.ok(note, `${heading} has no framing line`);
128
154
  const words = note[1].replace(/<[^>]+>/g, ' ').trim().split(/\s+/).length;
129
155
  assert.ok(words <= 30, `${heading}'s intro is ${words} words — a reader meets it before anything actionable`);
@@ -16,7 +16,11 @@ import { fileURLToPath } from 'node:url';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
18
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
- const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
19
+ // board-room.js since ADR 0266, not government.js: every assertion in this file
20
+ // is about the sitting card, the ballot and the vote form, and all three moved
21
+ // with the room onto its own page. The name stays `CLIENT` because that is what
22
+ // each assertion below reads — this file has exactly one source.
23
+ const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'board-room.js'), 'utf8');
20
24
  const { BASE_TEMPLATE } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
21
25
  const board = require(path.join(ROOT, 'modules', 'government', 'board.js'));
22
26
 
@@ -177,7 +177,11 @@ test('sidebar groups read by audience, in order', () => {
177
177
  // 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
178
178
  // 'project-settings' joins the Government group at the metic floor (task 1003280,
179
179
  // goal 1000072) — it sits before 'government', which is the one archon-only item.
180
- assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'harbor', 'gate', 'sessions', 'project-settings', 'government']);
180
+ // 'board-room' leads the group since ADR 0266: it is the one page here you ACT
181
+ // on (a sitting waits on a decision; the rest is monitoring), the same ordering
182
+ // rule the Community group's first item follows. Before that carve it had no
183
+ // item at all — it was a tab behind the one labelled "Permissions".
184
+ assert.deepEqual(nav[4].items.map((i) => i.id), ['board-room', 'watch', 'harbor', 'gate', 'sessions', 'project-settings', 'government']);
181
185
  });
182
186
 
183
187
  test('Economy lives in Docs and points at the reward-schedule page', () => {
@@ -38,6 +38,7 @@ const SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'ut
38
38
  // requireGovernmentPage page.view.government metic+ (ADR 0157)
39
39
  // requireProjectCuratePage project.curate metic+
40
40
  // requireGovernmentManagePage government.manage archon
41
+ // requireBoardVotePage board.vote.cast xenos+ (ADR 0266)
41
42
  const ORACLE = {
42
43
  // Any signed-in builder — no rank floor.
43
44
  '/builders/work': 'requireBuilderPage',
@@ -93,6 +94,16 @@ const ORACLE = {
93
94
  // tab seals itself for non-archons.
94
95
  '/builders/government': 'requireGovernmentPage',
95
96
 
97
+ // ADR 0266: the Board Room, carved out of the tab strip above onto its own
98
+ // page. It is the ONE hall page whose gate is deliberately WIDER than the
99
+ // group it sits in — `board.vote.cast` floors at xenos, on purpose, so that a
100
+ // constitution widening its board below Metic gets members who can reach the
101
+ // room rather than members who can only vote by curl. The floor is the coarse
102
+ // gate; membership is checked in-handler per item against that item's own
103
+ // snapshotted constitution, so a holder who sits on no board sees the room and
104
+ // no ballot (ADR 0175 §9 made the ballot open on purpose).
105
+ '/builders/board-room': 'requireBoardVotePage',
106
+
96
107
  // Deliberately UNGATED — the landing card, the rank ladder, the reward
97
108
  // schedule and a builder's public portfolio all render for a stranger.
98
109
  //
@@ -113,11 +113,17 @@ test('Tasks tab anchors exist in work.js makeTabs', () => {
113
113
  }
114
114
  });
115
115
 
116
- test('Government section anchors exist in government.js makeTabs (the R15 three-section IA)', () => {
116
+ // TWO rooms since ADR 0266 (was three): the Board Room became its own page at
117
+ // /board-room, so `#board-room` is no longer a tab id here and the palette must
118
+ // not declare it as a sub-destination — it is read live from the sidebar like
119
+ // every other page. The counts stay exact rather than becoming `>= 2`, because
120
+ // the whole point of this test is that a tab and its jump cannot drift apart.
121
+ test('Government section anchors exist in government.js makeTabs', () => {
117
122
  const ids = [...read('government.js').matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
118
- assert.ok(ids.length === 3, `sanity: found the three government tabs (${ids})`);
123
+ assert.ok(ids.length === 2, `sanity: found the two government tabs (${ids})`);
124
+ assert.ok(!ids.includes('board-room'), 'the Board Room is a page, not a tab (ADR 0266)');
119
125
  const declared = declaredHrefs((x) => x.startsWith('/government#'));
120
- assert.ok(declared.length === 3, 'palette declares the three Government sections');
126
+ assert.ok(declared.length === 2, 'palette declares the two Government sections');
121
127
  for (const h of declared) {
122
128
  assert.ok(ids.includes(h.split('#')[1]), `${h} must be a real government.js tab id`);
123
129
  }
@@ -116,6 +116,14 @@ const STATIC_LADDER_FILES = [
116
116
  // which holds it against catalog.RANK_ORDER and holds the whole browser parser
117
117
  // equal to the server one.
118
118
  'modules/hall-ui/public/government.js',
119
+ // ADR 0266: the Board Room moved off the government page onto its own, and the
120
+ // sitting card's `decidedByHtml` expands the same `rank:<key>+` membership form
121
+ // to say who may vote on THAT sitting — so the carve took a copy of the ladder
122
+ // with it. Same reason as the row above (browser code cannot require the server
123
+ // module), and the same pinning: `tests/government_board_vote_ui.mjs` reads
124
+ // board-room.js and holds its predicate parser equal to the server's, and the
125
+ // pin below in this file holds the array itself against LIVE_RANK_LADDER.
126
+ 'modules/hall-ui/public/board-room.js',
119
127
  // task 1002487: the pure agent-definition validator applies the ADR 0043
120
128
  // sub-Metic floor to an agent's declared scope, so it needs the ladder to
121
129
  // compare against. It is one of the "dependency-free" cases this array exists