@bongos/core 1.19.231 → 1.19.233
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 +47 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +123 -3
- package/docs/api-reference.md +4 -2
- package/modules/government/board.js +178 -6
- package/modules/government/config.js +31 -0
- package/modules/government/db.js +99 -0
- package/modules/government/migrations/government_007_amendments.sql +25 -0
- package/modules/government/migrations/government_008_closed_items_index.sql +17 -0
- package/modules/government/routes/government.js +55 -0
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/government.js +122 -19
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +6 -0
- package/src/bongos/serve-internal.js +8 -1
- package/src/module-api.js +5 -1
- package/tests/government_board_amendment.mjs +226 -0
- package/tests/government_board_view.mjs +129 -0
- package/tests/government_seed.mjs +2 -0
- package/tests/hall_page_gate_map.mjs +6 -2
|
@@ -401,30 +401,121 @@
|
|
|
401
401
|
|
|
402
402
|
// ---- the three-section IA (R15) -------------------------------------------
|
|
403
403
|
|
|
404
|
-
// Tab order is the spec's — Constitution · Board Room · Ranks & Permissions
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
function wireTabs() {
|
|
404
|
+
// Tab order is the spec's — Constitution · Board Room · Ranks & Permissions.
|
|
405
|
+
// Since R16 the Board Room is furnished, so a non-archon (who cannot use the
|
|
406
|
+
// admin tab) lands there; the Archon still lands on Ranks, their working
|
|
407
|
+
// surface. Flip the non-archon initial to 'constitution' when R18 lands.
|
|
408
|
+
function wireTabs({ isArchon }) {
|
|
409
409
|
window.OTBKit.makeTabs({
|
|
410
410
|
mount: document.getElementById('gov-tabs'),
|
|
411
411
|
hash: true,
|
|
412
|
-
initial: 'ranks',
|
|
412
|
+
initial: isArchon ? 'ranks' : 'board-room',
|
|
413
413
|
tabs: [
|
|
414
414
|
{ id: 'constitution', label: 'Constitution', panel: 'gov-panel-constitution' },
|
|
415
415
|
{ id: 'board-room', label: 'Board Room', panel: 'gov-panel-board' },
|
|
416
416
|
{ id: 'ranks', label: 'Ranks & Permissions', panel: 'gov-panel-ranks' },
|
|
417
417
|
],
|
|
418
418
|
});
|
|
419
|
-
// The
|
|
420
|
-
//
|
|
421
|
-
// the R18 read exists would mean hardcoding a value that drifts.
|
|
419
|
+
// The Constitution frame stays an empty state until R18's read exists —
|
|
420
|
+
// rendering live values without it would mean hardcoding what drifts.
|
|
422
421
|
document.getElementById('gov-constitution').innerHTML = window.OTBKit.emptyStateHtml(
|
|
423
422
|
'The live constitution — what this project runs today, and how it has changed — appears here when the Board Room opens.',
|
|
424
423
|
);
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- the Board Room panel (R16) --------------------------------------------
|
|
427
|
+
|
|
428
|
+
// The room renders what the server assembled: open items with the database's
|
|
429
|
+
// own countdown, the OPEN BALLOT (people, directions, timestamps, objections),
|
|
430
|
+
// and the closed history. Nothing here decides anything — casting arrives with
|
|
431
|
+
// R17; this panel is the accountability surface (§9: a board whose votes are
|
|
432
|
+
// invisible constrains nobody).
|
|
433
|
+
|
|
434
|
+
function fmtRemaining(seconds) {
|
|
435
|
+
if (seconds === null || seconds === undefined) return null;
|
|
436
|
+
const s = Number(seconds);
|
|
437
|
+
if (!Number.isFinite(s) || s <= 0) return 'closing…';
|
|
438
|
+
if (s < 90) return `closes in ${Math.max(1, Math.round(s))}s`;
|
|
439
|
+
if (s < 5400) return `closes in ${Math.round(s / 60)}m`;
|
|
440
|
+
return `closes in ${(s / 3600).toFixed(1)}h`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function subjectLine(item) {
|
|
444
|
+
if (item.subject_type === 'full_idea') {
|
|
445
|
+
const title = item.subject ? item.subject.title : `idea ${item.subject_id}`;
|
|
446
|
+
return `<a href="/#/idea/${escapeHtml(String(item.subject_id))}">${escapeHtml(title)}</a> <span class="gov-pill gov-pill--floor">Full Idea</span>`;
|
|
447
|
+
}
|
|
448
|
+
return `Constitutional amendment <span class="gov-pill gov-pill--floor">amendment</span>`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function clockLine(item) {
|
|
452
|
+
if (item.closes_at) {
|
|
453
|
+
const rem = fmtRemaining(item.remaining_seconds);
|
|
454
|
+
return rem || 'timed sitting';
|
|
455
|
+
}
|
|
456
|
+
const rule = item.constitution && item.constitution.pass_rule;
|
|
457
|
+
return rule === 'first_ratifier'
|
|
458
|
+
? 'no clock — closes on the first vote'
|
|
459
|
+
: 'no clock';
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function ballotHtml(item, viewerId) {
|
|
463
|
+
if (!item.votes.length) {
|
|
464
|
+
return `<p class="gov-empty">No votes yet${item.closed_at ? ' — closed unopposed' : ''}.</p>`;
|
|
465
|
+
}
|
|
466
|
+
const rows = item.votes.map((v) => {
|
|
467
|
+
const who = escapeHtml(v.display_name || v.github_login || `builder ${v.voter_id}`);
|
|
468
|
+
const mine = String(v.voter_id) === String(viewerId) ? ' <span class="gov-held-note">your vote</span>' : '';
|
|
469
|
+
const when = escapeHtml(String(v.cast_at).slice(0, 16).replace('T', ' '));
|
|
470
|
+
const objection = v.direction === 'no'
|
|
471
|
+
? `<div class="gov-perm__guards">objected to <code>${escapeHtml(v.section_key || '')}</code> — ${escapeHtml(v.reason_md || '')}</div>`
|
|
472
|
+
: '';
|
|
473
|
+
return `<div class="gov-assign-row"><span class="gov-assign-row__who">${who}${mine}</span>
|
|
474
|
+
<span class="gov-pill ${v.direction === 'no' ? 'gov-pill--locked' : 'gov-pill--seeded'}">${v.direction}</span>
|
|
475
|
+
<span class="muted">${when}</span>${objection}</div>`;
|
|
476
|
+
}).join('');
|
|
477
|
+
return `<div class="gov-section">${rows}</div>`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function itemCard(item, viewerId) {
|
|
481
|
+
const yes = item.votes.filter((v) => v.direction === 'yes').length;
|
|
482
|
+
const no = item.votes.filter((v) => v.direction === 'no').length;
|
|
483
|
+
const state = item.closed_at
|
|
484
|
+
? `<span class="gov-pill ${item.outcome === 'passed' ? 'gov-pill--seeded' : 'gov-pill--locked'}">${escapeHtml(item.outcome || '')}</span>`
|
|
485
|
+
: `<span class="gov-pill gov-pill--custom">open</span> <span class="muted">${escapeHtml(clockLine(item))}</span>`;
|
|
486
|
+
const amendmentDetail = item.subject_type === 'constitutional_amendment' && item.subject
|
|
487
|
+
? `<div class="gov-perm__guards">proposes: <code>${escapeHtml(JSON.stringify(item.subject.proposed))}</code>${item.subject.rationale_md ? ` — ${escapeHtml(item.subject.rationale_md)}` : ''}</div>`
|
|
488
|
+
: '';
|
|
489
|
+
const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
|
|
490
|
+
const myLine = item.closed_at ? '' : (mine
|
|
491
|
+
? `<p class="gov-grid-note">You voted <strong>${escapeHtml(mine.direction)}</strong>. One vote per member — no changes.</p>`
|
|
492
|
+
: `<p class="gov-grid-note">You have not voted on this item. Casting arrives with the vote form.</p>`);
|
|
493
|
+
return `<section class="gov-section">
|
|
494
|
+
<div class="gov-section__head">${subjectLine(item)} ${state}</div>
|
|
495
|
+
${amendmentDetail}
|
|
496
|
+
<p class="gov-grid-note">tally: ${yes} yes · ${no} no — sitting under <code>${escapeHtml((item.constitution && item.constitution.pass_rule) || '?')}</code>, membership <code>${escapeHtml((item.constitution && item.constitution.membership) || '?')}</code></p>
|
|
497
|
+
${myLine}
|
|
498
|
+
${ballotHtml(item, viewerId)}
|
|
499
|
+
</section>`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function loadBoard() {
|
|
503
|
+
const box = document.getElementById('gov-board');
|
|
504
|
+
let data;
|
|
505
|
+
try {
|
|
506
|
+
data = await fetchJson('/government/board/items');
|
|
507
|
+
} catch (err) {
|
|
508
|
+
if (err && err.kind === '401') return;
|
|
509
|
+
box.innerHTML = `<p class="gov-empty">Could not load the Board Room just now.</p>`;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const viewerId = data.viewer_id;
|
|
513
|
+
const open = (data.open || []).map((i) => itemCard(i, viewerId)).join('');
|
|
514
|
+
const closed = (data.closed || []).map((i) => itemCard(i, viewerId)).join('');
|
|
515
|
+
box.innerHTML =
|
|
516
|
+
(open || window.OTBKit.emptyStateHtml('Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.'))
|
|
517
|
+
+ `<h2 class="gov-h">Closed sittings</h2>`
|
|
518
|
+
+ (closed || `<p class="gov-empty">No sitting has closed yet.</p>`);
|
|
428
519
|
}
|
|
429
520
|
|
|
430
521
|
// ---- init ----------------------------------------------------------------
|
|
@@ -435,11 +526,20 @@
|
|
|
435
526
|
document.getElementById('gov-sub').textContent = 'Restricted.';
|
|
436
527
|
}
|
|
437
528
|
|
|
529
|
+
// Since R16 the page shell admits page.view.government (metic+), and only the
|
|
530
|
+
// RANKS tab is the archon admin surface — so the seal narrowed from the whole
|
|
531
|
+
// page to that one panel. Cosmetic defence-in-depth as ever: every
|
|
532
|
+
// /government/* CRUD endpoint still gates on government.manage server-side,
|
|
533
|
+
// and the board read carries its own atom.
|
|
534
|
+
function sealRanksPanel() {
|
|
535
|
+
const panel = document.getElementById('gov-panel-ranks');
|
|
536
|
+
const body = document.getElementById('gov-body');
|
|
537
|
+
// Replace the admin surface with the sealed note, inside its own tab only.
|
|
538
|
+
panel.innerHTML = '<p class="gov-sealed">Ranks & Permissions is the Archon administration surface. The Constitution and the Board Room are yours to read.</p>';
|
|
539
|
+
body.hidden = false;
|
|
540
|
+
}
|
|
541
|
+
|
|
438
542
|
async function init() {
|
|
439
|
-
// Confirm rank for a friendly sealed notice; the server gates the page and
|
|
440
|
-
// every /government endpoint on `government.manage` regardless (cosmetic
|
|
441
|
-
// defence-in-depth). government.manage is system:true, so only the archon
|
|
442
|
-
// rank can hold it — rank is an accurate client-side proxy.
|
|
443
543
|
let me;
|
|
444
544
|
try {
|
|
445
545
|
me = await fetchJson('/me');
|
|
@@ -449,17 +549,20 @@
|
|
|
449
549
|
me = null;
|
|
450
550
|
}
|
|
451
551
|
const rank = (me && (me.builder || me).rank || '').toLowerCase();
|
|
452
|
-
|
|
552
|
+
const isArchon = rank === 'archon';
|
|
453
553
|
|
|
454
554
|
document.getElementById('gov-sub').textContent = 'How this project is governed — the constitution it runs, the Board Room, and the ranks & permissions that administer it.';
|
|
455
555
|
document.getElementById('gov-body').hidden = false;
|
|
456
|
-
wireTabs();
|
|
556
|
+
wireTabs({ isArchon });
|
|
557
|
+
loadBoard(); // the room loads for everyone the page admits
|
|
558
|
+
|
|
559
|
+
if (!isArchon) { sealRanksPanel(); return; }
|
|
457
560
|
wireForms();
|
|
458
561
|
try {
|
|
459
562
|
await load();
|
|
460
563
|
} catch (err) {
|
|
461
564
|
if (err && err.kind === '401') return;
|
|
462
|
-
if (err && err.kind === '403') {
|
|
565
|
+
if (err && err.kind === '403') { sealRanksPanel(); return; }
|
|
463
566
|
document.getElementById('gov-grid').innerHTML = `<p class="gov-empty">Could not load government data just now.</p>`;
|
|
464
567
|
}
|
|
465
568
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.233",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.233",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.233",
|
|
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",
|
|
@@ -251,6 +251,12 @@ const GOVERNMENT_PINS = {
|
|
|
251
251
|
// The XENOS-floored atom is the COARSE gate by design; the real wall is the
|
|
252
252
|
// in-handler membership re-check against the item's snapshotted constitution.
|
|
253
253
|
'POST /government/board/items/:itemId/votes': 'perm:board.vote.cast',
|
|
254
|
+
// R14: propose a constitutional amendment (the board's only proposed subject —
|
|
255
|
+
// a Full Idea's window opens automatically on grade pass).
|
|
256
|
+
'POST /government/board/items': 'perm:board.item.open',
|
|
257
|
+
// R16: the Board Room view — the open ballot is visible to whoever may cast
|
|
258
|
+
// (xenos-floored atom; the room teaches as well as governs, criterion C5).
|
|
259
|
+
'GET /government/board/items': 'perm:board.vote.cast',
|
|
254
260
|
};
|
|
255
261
|
|
|
256
262
|
// Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
|
|
@@ -789,8 +789,15 @@ function mountInternalSurfaces(app) {
|
|
|
789
789
|
|| SESSIONS_PAGE_RE.test(norm)) {
|
|
790
790
|
return gdsAuth.requireGovernmentPage(req, res, next);
|
|
791
791
|
}
|
|
792
|
+
// R16 (goal 1000069) widened the government shell from the manage atom
|
|
793
|
+
// (archon) to page.view.government (metic+): since R15 the page is THREE
|
|
794
|
+
// rooms — Constitution, Board Room, Ranks & Permissions — and only the
|
|
795
|
+
// third is the archon admin surface. Its DATA routes all still gate on
|
|
796
|
+
// government.manage individually (defence in depth unchanged); the board +
|
|
797
|
+
// constitution reads carry their own atoms. The shell follows the widest
|
|
798
|
+
// legitimate audience — the audience changed when the page did.
|
|
792
799
|
if (GOVERNMENT_PAGE_RE.test(norm)) {
|
|
793
|
-
return gdsAuth.
|
|
800
|
+
return gdsAuth.requireGovernmentPage(req, res, next);
|
|
794
801
|
}
|
|
795
802
|
if (PEOPLE_PAGE_RE.test(norm) || SCOUTING_PAGE_RE.test(norm)) {
|
|
796
803
|
return gdsAuth.requireProjectCuratePage(req, res, next);
|
package/src/module-api.js
CHANGED
|
@@ -889,8 +889,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
889
889
|
// landed since 1.19.229 with no explicit bump. run 32131450528. (task 1002620)
|
|
890
890
|
// 1.19.231 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
891
891
|
// landed since 1.19.230 with no explicit bump. run 32133132988. (task 1002620)
|
|
892
|
+
// 1.19.232 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
893
|
+
// landed since 1.19.231 with no explicit bump. run 32133691409. (task 1002620)
|
|
894
|
+
// 1.19.233 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
895
|
+
// landed since 1.19.232 with no explicit bump. run 32160823445. (task 1002620)
|
|
892
896
|
// ---------------------------------------------------------------------------
|
|
893
|
-
const CORE_VERSION = '1.19.
|
|
897
|
+
const CORE_VERSION = '1.19.233'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
894
898
|
|
|
895
899
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
896
900
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// tests/government_board_amendment.mjs — constitutional amendments as the
|
|
2
|
+
// board's second subject (BV1.R14 of goal 1000069, ADR 0175 §7).
|
|
3
|
+
//
|
|
4
|
+
// DB-free (db monkeypatched; the apply runs against a REAL temp file so the
|
|
5
|
+
// atomic-write path is exercised, not stubbed). The invariants:
|
|
6
|
+
//
|
|
7
|
+
// 1. A PROPOSAL IS VALIDATED STRICTLY, NEVER SANITIZED — a config file falls
|
|
8
|
+
// back field-by-field so a boot never breaks; a proposal that fell back
|
|
9
|
+
// would put text before the board its proposer never wrote. Every field
|
|
10
|
+
// required, every refusal named.
|
|
11
|
+
// 2. THE MONARCHY ACT WORKS END TO END — the sole Archon proposes, votes on
|
|
12
|
+
// their OWN amendment (anti-self is a full_idea rule; §7 blesses this and
|
|
13
|
+
// forbids a bootstrap exception), first_ratifier closes it, and the
|
|
14
|
+
// instance pack now carries the new constitution.
|
|
15
|
+
// 3. ANTI-SELF STILL BINDS FOR IDEAS — the carve-out is subject-typed, and
|
|
16
|
+
// the full_idea case is re-pinned right here beside it.
|
|
17
|
+
// 4. A RETURNED AMENDMENT APPLIES NOTHING; a passed one books NO credit
|
|
18
|
+
// (the R12 contract's subject_type filter) and applies AFTER the close.
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
30
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
31
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
32
|
+
const config = require(path.join(GOV, 'config.js'));
|
|
33
|
+
|
|
34
|
+
const { validateProposedConstitution, proposeAmendment, castVote, maybeCloseAfterVote } = board;
|
|
35
|
+
|
|
36
|
+
const CONSENT_90 = Object.freeze({
|
|
37
|
+
membership: 'rank:archon', pass_rule: 'consent', window_minutes: 90, close_early_on_full_turnout: true,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function withDb(stubs, fn) {
|
|
41
|
+
const originals = {};
|
|
42
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
|
|
43
|
+
try { return await fn(); }
|
|
44
|
+
finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── invariant 1: strict validation ──────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
test('every field is required and every refusal is named — no silent correction', () => {
|
|
50
|
+
assert.equal(validateProposedConstitution(CONSENT_90).ok, true);
|
|
51
|
+
assert.equal(validateProposedConstitution({ ...CONSENT_90, window_minutes: null }).ok, true, 'null clock is a legal explicit choice');
|
|
52
|
+
|
|
53
|
+
const cases = [
|
|
54
|
+
[null, 'proposal_not_an_object'],
|
|
55
|
+
['consent', 'proposal_not_an_object'],
|
|
56
|
+
[{ ...CONSENT_90, extra: true }, 'unknown_field'],
|
|
57
|
+
[{ ...CONSENT_90, membership: 'everyone' }, 'bad_membership'],
|
|
58
|
+
[{ ...CONSENT_90, membership: undefined }, 'bad_membership'],
|
|
59
|
+
[{ ...CONSENT_90, pass_rule: 'majority' }, 'bad_pass_rule'],
|
|
60
|
+
[{ ...CONSENT_90, window_minutes: 0 }, 'bad_window_minutes'],
|
|
61
|
+
[{ ...CONSENT_90, window_minutes: 1.5 }, 'bad_window_minutes'],
|
|
62
|
+
[{ ...CONSENT_90, close_early_on_full_turnout: 'yes' }, 'bad_close_early'],
|
|
63
|
+
];
|
|
64
|
+
for (const [raw, reason] of cases) {
|
|
65
|
+
const v = validateProposedConstitution(raw);
|
|
66
|
+
assert.equal(v.ok, false, `${JSON.stringify(raw)} must be refused`);
|
|
67
|
+
assert.equal(v.reason, reason);
|
|
68
|
+
}
|
|
69
|
+
// THE CONTRAST WITH THE SANITIZER, stated as an assertion: the sanitizer
|
|
70
|
+
// silently corrects a bad pass_rule; the proposal validator refuses it.
|
|
71
|
+
assert.equal(config.sanitizeBoard({ ...CONSENT_90, pass_rule: 'majority' }).pass_rule, 'first_ratifier');
|
|
72
|
+
assert.equal(validateProposedConstitution({ ...CONSENT_90, pass_rule: 'majority' }).ok, false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── propose: files the row and opens the window in the same breath ──────────
|
|
76
|
+
|
|
77
|
+
test('a proposal files the amendment and opens its window under the constitution IN FORCE', async () => {
|
|
78
|
+
let opened = null;
|
|
79
|
+
await withDb({
|
|
80
|
+
createAmendment: async (args) => ({ id: '5', proposed: args.proposed, rationale_md: args.rationaleMd, proposed_by: args.proposedBy }),
|
|
81
|
+
openBoardItem: async (args) => { opened = args; return { id: '21', ...args }; },
|
|
82
|
+
}, async () => {
|
|
83
|
+
const out = await proposeAmendment({ proposal: CONSENT_90, rationaleMd: 'time to open up', proposedBy: '42' });
|
|
84
|
+
assert.equal(out.ok, true);
|
|
85
|
+
assert.equal(out.amendment.id, '5');
|
|
86
|
+
});
|
|
87
|
+
assert.equal(opened.subjectType, 'constitutional_amendment');
|
|
88
|
+
assert.equal(opened.subjectId, '5');
|
|
89
|
+
assert.equal(opened.openedBy, '42', 'an amendment HAS a human opener, unlike a grade-opened idea');
|
|
90
|
+
assert.equal(opened.constitution.pass_rule, 'first_ratifier',
|
|
91
|
+
'the window runs under the constitution IN FORCE (this checkout: monarchy), not the proposed one');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('an invalid proposal files NOTHING — refused before any write', async () => {
|
|
95
|
+
await withDb({
|
|
96
|
+
createAmendment: async () => { throw new Error('must not be reached'); },
|
|
97
|
+
openBoardItem: async () => { throw new Error('must not be reached'); },
|
|
98
|
+
}, async () => {
|
|
99
|
+
assert.equal((await proposeAmendment({ proposal: { ...CONSENT_90, pass_rule: 'majority' }, proposedBy: '42' })).ok, false);
|
|
100
|
+
assert.equal((await proposeAmendment({ proposal: CONSENT_90, proposedBy: null })).reason, 'missing_proposer');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── invariants 2+3: the monarchy act, and the subject-typed anti-self ────────
|
|
105
|
+
|
|
106
|
+
const MONARCHY = Object.freeze({ membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true });
|
|
107
|
+
const amendmentItem = (over = {}) => ({
|
|
108
|
+
id: '21', subject_type: 'constitutional_amendment', subject_id: '5',
|
|
109
|
+
opened_by: '42', closed_at: null, expired: false, constitution: MONARCHY, ...over,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the monarchy act end to end: the sole Archon ratifies their OWN amendment and the pack changes', async () => {
|
|
113
|
+
const tmp = path.join(os.tmpdir(), `gov-instance-${process.pid}-${Math.floor(performance.now() * 1000)}.json`);
|
|
114
|
+
// Redirect the instance-pack write to a temp file — board.js resolves
|
|
115
|
+
// applyBoardAmendment by property lookup at call time for exactly this.
|
|
116
|
+
const origApply = config.applyBoardAmendment;
|
|
117
|
+
config.applyBoardAmendment = (proposed) => origApply(proposed, { instancePath: tmp });
|
|
118
|
+
try {
|
|
119
|
+
const karma = [];
|
|
120
|
+
let votes = [];
|
|
121
|
+
let authorAsked = 0;
|
|
122
|
+
await withDb({
|
|
123
|
+
getBoardItemForVote: async () => amendmentItem(),
|
|
124
|
+
builderHoldsRankActive: async (builderId, rankKey) => builderId === '42' && rankKey === 'archon',
|
|
125
|
+
insertBoardVote: async (args) => { const v = { id: '1', voter_id: args.voterId, direction: args.direction }; votes.push(v); return v; },
|
|
126
|
+
insertVoteKarma: async () => {},
|
|
127
|
+
getVotesForItem: async () => votes,
|
|
128
|
+
getActiveBuildersWithRank: async () => [{ builder_id: '42', github_login: 'sole-archon', display_name: 'Sole Archon' }],
|
|
129
|
+
closeBoardItem: async ({ outcome, afterClose }) => {
|
|
130
|
+
const closed = { ...amendmentItem(), closed_at: 'T', outcome };
|
|
131
|
+
assert.equal(afterClose, null, 'an amendment books NO credit — the R12 contract filter');
|
|
132
|
+
return closed;
|
|
133
|
+
},
|
|
134
|
+
getAmendment: async (id) => { assert.equal(id, '5'); return { id: '5', proposed: CONSENT_90 }; },
|
|
135
|
+
insertAuthorRatifyKarma: async (args) => { karma.push(args); return true; },
|
|
136
|
+
// reached ONLY by the post-close ratify karma, never by the vote's
|
|
137
|
+
// anti-self (the carve-out) — the counter proves the order.
|
|
138
|
+
getBoardItemAuthor: async () => { authorAsked += 1; return '42'; },
|
|
139
|
+
}, async () => {
|
|
140
|
+
// The PROPOSER casts on their own amendment — legal (§7) — and it closes
|
|
141
|
+
// immediately under first_ratifier.
|
|
142
|
+
const cast = await castVote({ itemId: 21, voterId: '42', direction: 'yes' });
|
|
143
|
+
assert.equal(cast.ok, true, `the sole Archon's vote on their own amendment must land: ${JSON.stringify(cast)}`);
|
|
144
|
+
assert.equal(authorAsked, 0, 'anti-self was not even consulted for the amendment vote');
|
|
145
|
+
const closed = await maybeCloseAfterVote(21);
|
|
146
|
+
assert.equal(closed.closed, true);
|
|
147
|
+
assert.equal(closed.outcome, 'passed');
|
|
148
|
+
assert.ok(closed.applied_constitution, 'the amendment was applied on close');
|
|
149
|
+
assert.equal(closed.applied_constitution.pass_rule, 'consent', 'the project now runs consent');
|
|
150
|
+
assert.equal(closed.applied_constitution.window_minutes, 90);
|
|
151
|
+
assert.ok(authorAsked >= 1, 'the ratify karma DID resolve the author, after the close');
|
|
152
|
+
});
|
|
153
|
+
assert.equal(karma.length, 1, 'the proposer earned the ratification karma (+3, same act as an idea ratify)');
|
|
154
|
+
const onDisk = JSON.parse(fs.readFileSync(tmp, 'utf8'));
|
|
155
|
+
assert.equal(onDisk.board.pass_rule, 'consent', 'the TEMP instance pack carries the new constitution');
|
|
156
|
+
} finally {
|
|
157
|
+
config.applyBoardAmendment = origApply;
|
|
158
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* not created */ }
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('anti-self STILL binds for a full_idea — the carve-out is subject-typed', async () => {
|
|
163
|
+
await withDb({
|
|
164
|
+
getBoardItemForVote: async () => amendmentItem({ subject_type: 'full_idea', subject_id: '7' }),
|
|
165
|
+
builderHoldsRankActive: async () => true,
|
|
166
|
+
getBoardItemAuthor: async () => '42',
|
|
167
|
+
insertBoardVote: async () => { throw new Error('must not be reached'); },
|
|
168
|
+
}, async () => {
|
|
169
|
+
assert.deepEqual(await castVote({ itemId: 21, voterId: '42', direction: 'yes' }),
|
|
170
|
+
{ ok: false, reason: 'anti_self_vote' });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a RETURNED amendment applies nothing', async () => {
|
|
175
|
+
await withDb({
|
|
176
|
+
getVotesForItem: async () => [{ voter_id: '9', direction: 'no', reason_md: 'r', section_key: 'fit' }],
|
|
177
|
+
closeBoardItem: async ({ outcome, afterClose }) => {
|
|
178
|
+
assert.equal(afterClose, null, 'no credit hook for an amendment either way');
|
|
179
|
+
return { ...amendmentItem(), closed_at: 'T', outcome };
|
|
180
|
+
},
|
|
181
|
+
getAmendment: async () => { throw new Error('must not be reached — a return applies nothing'); },
|
|
182
|
+
getBoardItemAuthor: async () => { throw new Error('must not be reached'); },
|
|
183
|
+
}, async () => {
|
|
184
|
+
const out = await board.closeItem(amendmentItem());
|
|
185
|
+
assert.equal(out.outcome, 'returned');
|
|
186
|
+
assert.equal(out.applied_constitution, undefined);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ── the apply itself, against a real file ─────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
test('applyBoardAmendment writes the instance pack atomically and preserves its other keys', () => {
|
|
193
|
+
const tmp = path.join(os.tmpdir(), `gov-apply-${process.pid}-${Math.floor(performance.now() * 1000)}.json`);
|
|
194
|
+
try {
|
|
195
|
+
fs.writeFileSync(tmp, JSON.stringify({ ranks: { metic: { label: 'Trusted' } } }, null, 2));
|
|
196
|
+
const applied = config.applyBoardAmendment(CONSENT_90, { instancePath: tmp });
|
|
197
|
+
assert.equal(applied.pass_rule, 'consent');
|
|
198
|
+
const onDisk = JSON.parse(fs.readFileSync(tmp, 'utf8'));
|
|
199
|
+
assert.equal(onDisk.ranks.metic.label, 'Trusted', 'the pack\'s OTHER keys survive the apply');
|
|
200
|
+
assert.deepEqual(onDisk.board, { ...CONSENT_90 });
|
|
201
|
+
// and the resolver now serves it — one resolution path, R05's precedence intact
|
|
202
|
+
const resolved = config.resolveGovernmentConfig({ neutral: {}, instance: onDisk, env: {} });
|
|
203
|
+
assert.equal(resolved.board.pass_rule, 'consent');
|
|
204
|
+
// a missing instance pack is created whole
|
|
205
|
+
fs.unlinkSync(tmp);
|
|
206
|
+
config.applyBoardAmendment(MONARCHY, { instancePath: tmp });
|
|
207
|
+
assert.equal(JSON.parse(fs.readFileSync(tmp, 'utf8')).board.pass_rule, 'first_ratifier');
|
|
208
|
+
} finally {
|
|
209
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* gone */ }
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('the route pin names board.item.open, and the catalog predicted this exact route', () => {
|
|
214
|
+
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
215
|
+
assert.equal(rrc.MODULE_EXPECTED_RANKS.government['POST /government/board/items'],
|
|
216
|
+
`${rrc.PERM_PIN_PREFIX}board.item.open`);
|
|
217
|
+
const catalog = fs.readFileSync(path.join(GOV, 'catalog.js'), 'utf8');
|
|
218
|
+
assert.ok(catalog.includes('in practice this guards an amendment'),
|
|
219
|
+
'the R04 catalog guards note called it: the only proposed subject is an amendment');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
test('this checkout stays vanilla: no test may leave a real config/government.json behind', () => {
|
|
224
|
+
assert.equal(fs.existsSync(path.join(ROOT, 'config', 'government.json')), false,
|
|
225
|
+
'the instance pack is host-owned and ABSENT here — a test that writes it pollutes the vanilla-boot fixtures (government_config.mjs guards the same fact)');
|
|
226
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// tests/government_board_view.mjs — the Board Room view (BV1.R16 of goal
|
|
2
|
+
// 1000069, ADR 0175 §9): open items with their clock, the open ballot, and the
|
|
3
|
+
// closed history — assembled server-side, decided nowhere.
|
|
4
|
+
//
|
|
5
|
+
// DB-free (db monkeypatched, the sibling board tests' idiom). The invariants:
|
|
6
|
+
// 1. THE VIEW DECIDES NOTHING — it projects rows; the payload carries each
|
|
7
|
+
// item's SNAPSHOT so the room describes the rule the item actually runs
|
|
8
|
+
// under, and remaining time is the database's number, passed through.
|
|
9
|
+
// 2. SUBJECTS ARE ENRICHED, HONESTLY — an idea shows its title + author, an
|
|
10
|
+
// amendment its proposed block; a subject whose row is GONE projects
|
|
11
|
+
// subject:null rather than being dropped or invented.
|
|
12
|
+
// 3. THE BALLOT IS OPEN — voter identity rides every vote row (§9), and an
|
|
13
|
+
// objection carries its reason + section for the room to show.
|
|
14
|
+
// 4. THE READ IS GATED ON THE VOTE ATOM, NOT THE MANAGE ATOM — seeing the
|
|
15
|
+
// ballot is part of casting; the pin (and the generalized gate test in
|
|
16
|
+
// government_routes.mjs) hold the route to board.vote.cast.
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
27
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
28
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
29
|
+
|
|
30
|
+
const { boardRoomView } = board;
|
|
31
|
+
|
|
32
|
+
const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
|
|
33
|
+
|
|
34
|
+
async function withDb(stubs, fn) {
|
|
35
|
+
const originals = {};
|
|
36
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
|
|
37
|
+
try { return await fn(); }
|
|
38
|
+
finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('the assembled view: enrichment, the open ballot, the snapshot, the database clock — all passthrough', async () => {
|
|
42
|
+
const view = await withDb({
|
|
43
|
+
listOpenBoardItems: async () => [
|
|
44
|
+
{ id: 11n, subject_type: 'full_idea', subject_id: 7n, opened_by: null, opened_at: 'T1', closes_at: 'T2', remaining_seconds: 540, expired: false, closed_at: null, outcome: null, constitution: MONARCHY },
|
|
45
|
+
{ id: 12n, subject_type: 'constitutional_amendment', subject_id: 5n, opened_by: 42n, opened_at: 'T1', closes_at: null, remaining_seconds: null, expired: false, closed_at: null, outcome: null, constitution: MONARCHY },
|
|
46
|
+
],
|
|
47
|
+
listClosedBoardItems: async (limit) => { assert.equal(limit, 20); return [
|
|
48
|
+
{ id: 9, subject_type: 'full_idea', subject_id: 6, opened_by: null, opened_at: 'T0', closes_at: null, closed_at: 'T3', outcome: 'returned', constitution: MONARCHY },
|
|
49
|
+
]; },
|
|
50
|
+
getVotesForItems: async (ids) => {
|
|
51
|
+
assert.deepEqual(ids.map(String).sort(), ['11', '12', '9'].sort(), 'one ballot query for ALL items, open and closed');
|
|
52
|
+
return [
|
|
53
|
+
{ item_id: 11n, voter_id: 42n, github_login: 'first-member', display_name: 'First Member', direction: 'yes', reason_md: null, section_key: null, cast_at: 'T2' },
|
|
54
|
+
{ item_id: 9, voter_id: 43, github_login: 'second-member', display_name: null, direction: 'no', reason_md: 'fit is thin', section_key: 'fit', cast_at: 'T2' },
|
|
55
|
+
];
|
|
56
|
+
},
|
|
57
|
+
getIdeaSubjects: async (ids) => {
|
|
58
|
+
assert.deepEqual(ids.map(String).sort(), ['6', '7']);
|
|
59
|
+
return [{ id: 7n, title: 'A big idea', captured_by: 9n }]; // idea 6 is GONE — deleted subject
|
|
60
|
+
},
|
|
61
|
+
getAmendmentSubjects: async (ids) => {
|
|
62
|
+
assert.deepEqual(ids.map(String), ['5']);
|
|
63
|
+
return [{ id: 5n, proposed: { pass_rule: 'consent' }, rationale_md: 'open up', proposed_by: 42n }];
|
|
64
|
+
},
|
|
65
|
+
}, () => boardRoomView());
|
|
66
|
+
|
|
67
|
+
assert.equal(view.open.length, 2);
|
|
68
|
+
assert.equal(view.closed.length, 1);
|
|
69
|
+
|
|
70
|
+
const idea = view.open[0];
|
|
71
|
+
assert.deepEqual(idea.subject, { title: 'A big idea', author_id: '9' }, 'an idea shows its title + author (R17 greys anti-self from this)');
|
|
72
|
+
assert.equal(idea.remaining_seconds, 540, 'the DATABASE clock, passed through — the client never does clock math');
|
|
73
|
+
assert.deepEqual(idea.constitution, MONARCHY, 'the SNAPSHOT rides along — the room describes the rule the item runs under');
|
|
74
|
+
assert.equal(idea.votes.length, 1);
|
|
75
|
+
assert.equal(idea.votes[0].github_login, 'first-member', 'the ballot is OPEN — identity on every vote row');
|
|
76
|
+
|
|
77
|
+
const amendment = view.open[1];
|
|
78
|
+
assert.deepEqual(amendment.subject, { proposed: { pass_rule: 'consent' }, rationale_md: 'open up', author_id: '42' });
|
|
79
|
+
assert.equal(amendment.remaining_seconds, null, 'no clock is null, not zero');
|
|
80
|
+
|
|
81
|
+
const gone = view.closed[0];
|
|
82
|
+
assert.equal(gone.subject, null, 'a deleted subject projects null — shown honestly, never dropped or invented');
|
|
83
|
+
assert.equal(gone.outcome, 'returned');
|
|
84
|
+
assert.equal(gone.votes[0].section_key, 'fit', 'the objection travels with the closed history');
|
|
85
|
+
// every id is a string — Postgres bigints and JS numbers must compare equal downstream
|
|
86
|
+
for (const item of [...view.open, ...view.closed]) {
|
|
87
|
+
assert.equal(typeof item.id, 'string');
|
|
88
|
+
assert.equal(typeof item.subject_id, 'string');
|
|
89
|
+
for (const v of item.votes) assert.equal(typeof v.voter_id, 'string');
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('an empty board is two empty lists — no error, no placeholder rows', async () => {
|
|
94
|
+
const view = await withDb({
|
|
95
|
+
listOpenBoardItems: async () => [],
|
|
96
|
+
listClosedBoardItems: async () => [],
|
|
97
|
+
getVotesForItems: async (ids) => { assert.deepEqual(ids, []); return []; },
|
|
98
|
+
getIdeaSubjects: async (ids) => { assert.deepEqual(ids, []); return []; },
|
|
99
|
+
getAmendmentSubjects: async (ids) => { assert.deepEqual(ids, []); return []; },
|
|
100
|
+
}, () => boardRoomView());
|
|
101
|
+
assert.deepEqual(view, { open: [], closed: [] });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the reads are indexed and clock math stays in SQL (source pins)', () => {
|
|
105
|
+
const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
|
|
106
|
+
const open = src.slice(src.indexOf('async function listOpenBoardItems'), src.indexOf('async function listClosedBoardItems'));
|
|
107
|
+
assert.match(open, /EXTRACT\(EPOCH FROM \(closes_at - now\(\)\)\)/, 'remaining time is the DATABASE clock');
|
|
108
|
+
assert.match(open, /GREATEST\(0,/, 'a just-expired item reads 0, never negative');
|
|
109
|
+
const closed = src.slice(src.indexOf('async function listClosedBoardItems'), src.indexOf('async function getVotesForItems'));
|
|
110
|
+
assert.match(closed, /ORDER BY closed_at DESC/, 'newest closes first');
|
|
111
|
+
const migration = fs.readFileSync(path.join(GOV, 'migrations', 'government_008_closed_items_index.sql'), 'utf8');
|
|
112
|
+
assert.ok(migration.includes('government_board_items_closed_by_time'), 'the closed-history read is indexed (the R13 grader lesson, applied up front)');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the room is gated on the vote atom, and the page shell widened WITH the tab seal (the R16 pair)', () => {
|
|
116
|
+
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
117
|
+
assert.equal(rrc.MODULE_EXPECTED_RANKS.government['GET /government/board/items'],
|
|
118
|
+
`${rrc.PERM_PIN_PREFIX}board.vote.cast`, 'seeing the ballot is part of casting — never the manage atom');
|
|
119
|
+
// The page-gate widening and the client-side ranks seal land TOGETHER: the
|
|
120
|
+
// shell admitting metic+ without the admin tab sealing itself would render a
|
|
121
|
+
// broken tab; the seal without the widening would seal a page nobody new can
|
|
122
|
+
// reach. Both halves pinned here so neither can be reverted alone.
|
|
123
|
+
const serve = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'), 'utf8');
|
|
124
|
+
const idx = serve.indexOf('GOVERNMENT_PAGE_RE.test(norm)');
|
|
125
|
+
assert.ok(serve.slice(idx, idx + 200).includes('requireGovernmentPage'),
|
|
126
|
+
'the shell admits page.view.government (metic+), not the manage atom');
|
|
127
|
+
const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
128
|
+
assert.match(client, /sealRanksPanel/, 'the admin tab seals itself for non-archons');
|
|
129
|
+
});
|
|
@@ -39,6 +39,8 @@ const NON_GRANT_MIGRATIONS = new Set([
|
|
|
39
39
|
'government_003_board_tables.sql', // ADR 0175 Board Room schema (task 1003016)
|
|
40
40
|
'government_005_seeded_rank_descriptions.sql', // ADR 0174 seeded-description text fix (task 1003083)
|
|
41
41
|
'government_006_returned_items_index.sql', // R13 returned-item lookup index (task 1003026)
|
|
42
|
+
'government_007_amendments.sql', // R14 amendment proposals table (task 1003027)
|
|
43
|
+
'government_008_closed_items_index.sql', // R16 closed-history index (task 1003029)
|
|
42
44
|
]);
|
|
43
45
|
|
|
44
46
|
// Permission keys RENAMED after the grant migrations that first granted them.
|
|
@@ -67,8 +67,12 @@ const ORACLE = {
|
|
|
67
67
|
'/builders/people': 'requireProjectCuratePage',
|
|
68
68
|
'/builders/scouting': 'requireProjectCuratePage',
|
|
69
69
|
|
|
70
|
-
//
|
|
71
|
-
|
|
70
|
+
// Metic+ since R16 (goal 1000069): the Government page is three rooms —
|
|
71
|
+
// Constitution · Board Room · Ranks & Permissions — and only the third is the
|
|
72
|
+
// archon admin surface, whose DATA routes all still gate on government.manage
|
|
73
|
+
// individually. The shell follows the widest legitimate audience; the board
|
|
74
|
+
// reads carry the vote atom, the manage tab seals itself for non-archons.
|
|
75
|
+
'/builders/government': 'requireGovernmentPage',
|
|
72
76
|
|
|
73
77
|
// Deliberately UNGATED — the landing card, the rank ladder, the reward
|
|
74
78
|
// schedule and a builder's public portfolio all render for a stranger.
|