@bongos/core 1.19.232 → 1.19.234
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 +42 -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 +4 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +67 -1
- package/docs/api-reference.md +4 -2
- package/modules/government/board.js +98 -0
- package/modules/government/db.js +100 -0
- package/modules/government/migrations/government_008_closed_items_index.sql +17 -0
- package/modules/government/routes/government.js +33 -0
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/government.js +200 -22
- 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_view.mjs +129 -0
- package/tests/government_constitution_view.mjs +87 -0
- package/tests/government_routes.mjs +5 -2
- package/tests/government_seed.mjs +1 -0
- package/tests/hall_page_gate_map.mjs +6 -2
|
@@ -414,6 +414,39 @@ module.exports = function buildGovernmentRouter() {
|
|
|
414
414
|
}
|
|
415
415
|
});
|
|
416
416
|
|
|
417
|
+
// rank: perm board.vote.cast — the Board Room view: open items with their
|
|
418
|
+
// clock, the OPEN BALLOT, the closed history (R16 of goal 1000069). Gated on
|
|
419
|
+
// the XENOS-floored vote atom, deliberately: seeing the ballot is part of
|
|
420
|
+
// casting (§9 — a board whose votes are invisible constrains nobody), and the
|
|
421
|
+
// room teaches as well as governs (criterion C5). NOT government.manage —
|
|
422
|
+
// that is the admin tab's atom; the room must be visible to whoever the
|
|
423
|
+
// constitution may enfranchise. Membership still gates nothing here: this is
|
|
424
|
+
// a read, and the write (the vote) re-checks membership in-handler.
|
|
425
|
+
router.get('/government/board/items', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
|
|
426
|
+
try {
|
|
427
|
+
const view = await board.boardRoomView({ closedLimit: 20 });
|
|
428
|
+
res.json({ ...view, viewer_id: String(req.builder.id) });
|
|
429
|
+
} catch (err) {
|
|
430
|
+
console.error('[gds] GET /government/board/items', err);
|
|
431
|
+
res.fail('board_read_failed', { status: 500, message: 'internal error' });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// rank: perm board.vote.cast — the Constitution view (R18 of goal 1000069):
|
|
436
|
+
// the live board block plus the full amendment history, passed and returned
|
|
437
|
+
// alike. Same atom as the room — reading the rule you vote under is part of
|
|
438
|
+
// casting, and the section teaches (criterion C5). The live value resolves
|
|
439
|
+
// fresh per request, so an amendment applied a moment ago reads back
|
|
440
|
+
// immediately (the same instant-effect posture as everything else here).
|
|
441
|
+
router.get('/government/constitution', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
|
|
442
|
+
try {
|
|
443
|
+
res.json(await board.constitutionView({ historyLimit: 50 }));
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.error('[gds] GET /government/constitution', err);
|
|
446
|
+
res.fail('constitution_read_failed', { status: 500, message: 'internal error' });
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
417
450
|
// rank: perm board.item.open — put an AMENDMENT to the board (R14 of goal
|
|
418
451
|
// 1000069, ADR 0175 §7). The board's other subject never comes through here:
|
|
419
452
|
// a Full Idea's window opens AUTOMATICALLY on grade pass (R07), so requesting
|
|
@@ -99,6 +99,6 @@
|
|
|
99
99
|
<script src="/builders/palette.js" defer></script>
|
|
100
100
|
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
101
101
|
<script src="/builders/hall-kit.js"></script>
|
|
102
|
-
<script src="/builders/government.js?v=2026-08-18-
|
|
102
|
+
<script src="/builders/government.js?v=2026-08-18-constitution"></script>
|
|
103
103
|
</body>
|
|
104
104
|
</html>
|
|
@@ -401,30 +401,195 @@
|
|
|
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
|
+
// A non-archon lands on the Constitution (the page's IA claim: this is the
|
|
406
|
+
// page that answers "how is this project governed"); the Archon still lands
|
|
407
|
+
// on Ranks, their working surface.
|
|
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' : 'constitution',
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---- the Constitution panel (R18) -------------------------------------------
|
|
422
|
+
|
|
423
|
+
// Plain language over the three dials, straight from the SERVER's resolved
|
|
424
|
+
// value (never hardcoded — an amendment applied a moment ago reads back on
|
|
425
|
+
// the next load), plus the change history: every closed amendment sitting,
|
|
426
|
+
// passed and RETURNED alike, each with the rule it was decided under — so a
|
|
427
|
+
// switch can never be flipped quietly, and every change is auditable against
|
|
428
|
+
// the constitution of its day.
|
|
429
|
+
|
|
430
|
+
function membershipSentence(board) {
|
|
431
|
+
const m = String(board.membership || '');
|
|
432
|
+
const rank = m.startsWith('rank:') ? m.slice(5) : null;
|
|
433
|
+
return rank
|
|
434
|
+
? `Every active builder holding the <strong>${escapeHtml(rank)}</strong> rank sits on the board — membership follows the rank, live.`
|
|
435
|
+
: `Membership predicate: <code>${escapeHtml(m || 'unset')}</code>.`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function passRuleSentence(board) {
|
|
439
|
+
if (board.pass_rule === 'first_ratifier') {
|
|
440
|
+
return 'An item is decided by its <strong>first vote</strong> — yes ratifies, no returns. The day-one shape: one member acts, done.';
|
|
441
|
+
}
|
|
442
|
+
if (board.pass_rule === 'consent') {
|
|
443
|
+
return 'Items pass by <strong>consent</strong>: silence is consent, a single reasoned objection returns the item to its author, and a sitting that ends with no votes at all passes.';
|
|
444
|
+
}
|
|
445
|
+
return `Pass rule: <code>${escapeHtml(String(board.pass_rule || 'unset'))}</code>.`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function windowSentence(board) {
|
|
449
|
+
if (board.window_minutes === null || board.window_minutes === undefined) {
|
|
450
|
+
return 'There is <strong>no clock</strong> — an item stays open until a vote decides it.';
|
|
451
|
+
}
|
|
452
|
+
const early = board.close_early_on_full_turnout
|
|
453
|
+
? ', closing early the moment every member has voted'
|
|
454
|
+
: '';
|
|
455
|
+
return `A sitting runs <strong>${escapeHtml(String(board.window_minutes))} minutes</strong>${early}. An unvoted window nulls that member's vote — absence never blocks.`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function historyRow(h) {
|
|
459
|
+
const who = escapeHtml(h.proposer_name || h.proposer_login || (h.proposed_by ? `builder ${h.proposed_by}` : 'unknown'));
|
|
460
|
+
const when = escapeHtml(String(h.closed_at).slice(0, 16).replace('T', ' '));
|
|
461
|
+
const pill = h.outcome === 'passed'
|
|
462
|
+
? '<span class="gov-pill gov-pill--seeded">ratified</span>'
|
|
463
|
+
: '<span class="gov-pill gov-pill--locked">returned</span>';
|
|
464
|
+
const under = h.decided_under && h.decided_under.pass_rule
|
|
465
|
+
? ` · decided under <code>${escapeHtml(h.decided_under.pass_rule)}</code>`
|
|
466
|
+
: '';
|
|
467
|
+
return `<div class="gov-assign-row">
|
|
468
|
+
<span class="gov-assign-row__who">${who} proposed <code>${escapeHtml(JSON.stringify(h.proposed))}</code>${h.rationale_md ? ` — ${escapeHtml(h.rationale_md)}` : ''}</span>
|
|
469
|
+
${pill} <span class="muted">${when}${under}</span>
|
|
470
|
+
</div>`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function loadConstitution() {
|
|
474
|
+
const box = document.getElementById('gov-constitution');
|
|
475
|
+
let data;
|
|
476
|
+
try {
|
|
477
|
+
data = await fetchJson('/government/constitution');
|
|
478
|
+
} catch (err) {
|
|
479
|
+
if (err && err.kind === '401') return;
|
|
480
|
+
box.innerHTML = `<p class="gov-empty">Could not load the constitution just now.</p>`;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const b = data.board || {};
|
|
484
|
+
const history = data.history || [];
|
|
485
|
+
box.innerHTML = `
|
|
486
|
+
<section class="gov-section">
|
|
487
|
+
<div class="gov-section__head">What this project runs today</div>
|
|
488
|
+
<p class="gov-grid-note">${membershipSentence(b)}</p>
|
|
489
|
+
<p class="gov-grid-note">${passRuleSentence(b)}</p>
|
|
490
|
+
<p class="gov-grid-note">${windowSentence(b)}</p>
|
|
491
|
+
<p class="gov-grid-note muted">As configuration: <code>${escapeHtml(JSON.stringify(b))}</code>. Changing it is a board act — propose an amendment, and it is decided under the rule above.</p>
|
|
492
|
+
</section>
|
|
493
|
+
<h2 class="gov-h">How it has changed</h2>
|
|
494
|
+
${history.length
|
|
495
|
+
? `<div class="gov-section">${history.map(historyRow).join('')}</div>`
|
|
496
|
+
: `<p class="gov-empty">Never amended — this project still runs its founding constitution.</p>`}
|
|
497
|
+
`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---- the Board Room panel (R16) --------------------------------------------
|
|
501
|
+
|
|
502
|
+
// The room renders what the server assembled: open items with the database's
|
|
503
|
+
// own countdown, the OPEN BALLOT (people, directions, timestamps, objections),
|
|
504
|
+
// and the closed history. Nothing here decides anything — casting arrives with
|
|
505
|
+
// R17; this panel is the accountability surface (§9: a board whose votes are
|
|
506
|
+
// invisible constrains nobody).
|
|
507
|
+
|
|
508
|
+
function fmtRemaining(seconds) {
|
|
509
|
+
if (seconds === null || seconds === undefined) return null;
|
|
510
|
+
const s = Number(seconds);
|
|
511
|
+
if (!Number.isFinite(s) || s <= 0) return 'closing…';
|
|
512
|
+
if (s < 90) return `closes in ${Math.max(1, Math.round(s))}s`;
|
|
513
|
+
if (s < 5400) return `closes in ${Math.round(s / 60)}m`;
|
|
514
|
+
return `closes in ${(s / 3600).toFixed(1)}h`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function subjectLine(item) {
|
|
518
|
+
if (item.subject_type === 'full_idea') {
|
|
519
|
+
const title = item.subject ? item.subject.title : `idea ${item.subject_id}`;
|
|
520
|
+
return `<a href="/#/idea/${escapeHtml(String(item.subject_id))}">${escapeHtml(title)}</a> <span class="gov-pill gov-pill--floor">Full Idea</span>`;
|
|
521
|
+
}
|
|
522
|
+
return `Constitutional amendment <span class="gov-pill gov-pill--floor">amendment</span>`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function clockLine(item) {
|
|
526
|
+
if (item.closes_at) {
|
|
527
|
+
const rem = fmtRemaining(item.remaining_seconds);
|
|
528
|
+
return rem || 'timed sitting';
|
|
529
|
+
}
|
|
530
|
+
const rule = item.constitution && item.constitution.pass_rule;
|
|
531
|
+
return rule === 'first_ratifier'
|
|
532
|
+
? 'no clock — closes on the first vote'
|
|
533
|
+
: 'no clock';
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function ballotHtml(item, viewerId) {
|
|
537
|
+
if (!item.votes.length) {
|
|
538
|
+
return `<p class="gov-empty">No votes yet${item.closed_at ? ' — closed unopposed' : ''}.</p>`;
|
|
539
|
+
}
|
|
540
|
+
const rows = item.votes.map((v) => {
|
|
541
|
+
const who = escapeHtml(v.display_name || v.github_login || `builder ${v.voter_id}`);
|
|
542
|
+
const mine = String(v.voter_id) === String(viewerId) ? ' <span class="gov-held-note">your vote</span>' : '';
|
|
543
|
+
const when = escapeHtml(String(v.cast_at).slice(0, 16).replace('T', ' '));
|
|
544
|
+
const objection = v.direction === 'no'
|
|
545
|
+
? `<div class="gov-perm__guards">objected to <code>${escapeHtml(v.section_key || '')}</code> — ${escapeHtml(v.reason_md || '')}</div>`
|
|
546
|
+
: '';
|
|
547
|
+
return `<div class="gov-assign-row"><span class="gov-assign-row__who">${who}${mine}</span>
|
|
548
|
+
<span class="gov-pill ${v.direction === 'no' ? 'gov-pill--locked' : 'gov-pill--seeded'}">${v.direction}</span>
|
|
549
|
+
<span class="muted">${when}</span>${objection}</div>`;
|
|
550
|
+
}).join('');
|
|
551
|
+
return `<div class="gov-section">${rows}</div>`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function itemCard(item, viewerId) {
|
|
555
|
+
const yes = item.votes.filter((v) => v.direction === 'yes').length;
|
|
556
|
+
const no = item.votes.filter((v) => v.direction === 'no').length;
|
|
557
|
+
const state = item.closed_at
|
|
558
|
+
? `<span class="gov-pill ${item.outcome === 'passed' ? 'gov-pill--seeded' : 'gov-pill--locked'}">${escapeHtml(item.outcome || '')}</span>`
|
|
559
|
+
: `<span class="gov-pill gov-pill--custom">open</span> <span class="muted">${escapeHtml(clockLine(item))}</span>`;
|
|
560
|
+
const amendmentDetail = item.subject_type === 'constitutional_amendment' && item.subject
|
|
561
|
+
? `<div class="gov-perm__guards">proposes: <code>${escapeHtml(JSON.stringify(item.subject.proposed))}</code>${item.subject.rationale_md ? ` — ${escapeHtml(item.subject.rationale_md)}` : ''}</div>`
|
|
562
|
+
: '';
|
|
563
|
+
const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
|
|
564
|
+
const myLine = item.closed_at ? '' : (mine
|
|
565
|
+
? `<p class="gov-grid-note">You voted <strong>${escapeHtml(mine.direction)}</strong>. One vote per member — no changes.</p>`
|
|
566
|
+
: `<p class="gov-grid-note">You have not voted on this item. Casting arrives with the vote form.</p>`);
|
|
567
|
+
return `<section class="gov-section">
|
|
568
|
+
<div class="gov-section__head">${subjectLine(item)} ${state}</div>
|
|
569
|
+
${amendmentDetail}
|
|
570
|
+
<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>
|
|
571
|
+
${myLine}
|
|
572
|
+
${ballotHtml(item, viewerId)}
|
|
573
|
+
</section>`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function loadBoard() {
|
|
577
|
+
const box = document.getElementById('gov-board');
|
|
578
|
+
let data;
|
|
579
|
+
try {
|
|
580
|
+
data = await fetchJson('/government/board/items');
|
|
581
|
+
} catch (err) {
|
|
582
|
+
if (err && err.kind === '401') return;
|
|
583
|
+
box.innerHTML = `<p class="gov-empty">Could not load the Board Room just now.</p>`;
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const viewerId = data.viewer_id;
|
|
587
|
+
const open = (data.open || []).map((i) => itemCard(i, viewerId)).join('');
|
|
588
|
+
const closed = (data.closed || []).map((i) => itemCard(i, viewerId)).join('');
|
|
589
|
+
box.innerHTML =
|
|
590
|
+
(open || window.OTBKit.emptyStateHtml('Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.'))
|
|
591
|
+
+ `<h2 class="gov-h">Closed sittings</h2>`
|
|
592
|
+
+ (closed || `<p class="gov-empty">No sitting has closed yet.</p>`);
|
|
428
593
|
}
|
|
429
594
|
|
|
430
595
|
// ---- init ----------------------------------------------------------------
|
|
@@ -435,11 +600,20 @@
|
|
|
435
600
|
document.getElementById('gov-sub').textContent = 'Restricted.';
|
|
436
601
|
}
|
|
437
602
|
|
|
603
|
+
// Since R16 the page shell admits page.view.government (metic+), and only the
|
|
604
|
+
// RANKS tab is the archon admin surface — so the seal narrowed from the whole
|
|
605
|
+
// page to that one panel. Cosmetic defence-in-depth as ever: every
|
|
606
|
+
// /government/* CRUD endpoint still gates on government.manage server-side,
|
|
607
|
+
// and the board read carries its own atom.
|
|
608
|
+
function sealRanksPanel() {
|
|
609
|
+
const panel = document.getElementById('gov-panel-ranks');
|
|
610
|
+
const body = document.getElementById('gov-body');
|
|
611
|
+
// Replace the admin surface with the sealed note, inside its own tab only.
|
|
612
|
+
panel.innerHTML = '<p class="gov-sealed">Ranks & Permissions is the Archon administration surface. The Constitution and the Board Room are yours to read.</p>';
|
|
613
|
+
body.hidden = false;
|
|
614
|
+
}
|
|
615
|
+
|
|
438
616
|
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
617
|
let me;
|
|
444
618
|
try {
|
|
445
619
|
me = await fetchJson('/me');
|
|
@@ -449,17 +623,21 @@
|
|
|
449
623
|
me = null;
|
|
450
624
|
}
|
|
451
625
|
const rank = (me && (me.builder || me).rank || '').toLowerCase();
|
|
452
|
-
|
|
626
|
+
const isArchon = rank === 'archon';
|
|
453
627
|
|
|
454
628
|
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
629
|
document.getElementById('gov-body').hidden = false;
|
|
456
|
-
wireTabs();
|
|
630
|
+
wireTabs({ isArchon });
|
|
631
|
+
loadBoard(); // the room loads for everyone the page admits
|
|
632
|
+
loadConstitution(); // so does the rule it runs under (R18)
|
|
633
|
+
|
|
634
|
+
if (!isArchon) { sealRanksPanel(); return; }
|
|
457
635
|
wireForms();
|
|
458
636
|
try {
|
|
459
637
|
await load();
|
|
460
638
|
} catch (err) {
|
|
461
639
|
if (err && err.kind === '401') return;
|
|
462
|
-
if (err && err.kind === '403') {
|
|
640
|
+
if (err && err.kind === '403') { sealRanksPanel(); return; }
|
|
463
641
|
document.getElementById('gov-grid').innerHTML = `<p class="gov-empty">Could not load government data just now.</p>`;
|
|
464
642
|
}
|
|
465
643
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.234",
|
|
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.234",
|
|
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.234",
|
|
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",
|
|
@@ -254,6 +254,12 @@ const GOVERNMENT_PINS = {
|
|
|
254
254
|
// R14: propose a constitutional amendment (the board's only proposed subject —
|
|
255
255
|
// a Full Idea's window opens automatically on grade pass).
|
|
256
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',
|
|
260
|
+
// R18: the Constitution view — the live rule + the amendment history; reading
|
|
261
|
+
// the rule you vote under is part of casting.
|
|
262
|
+
'GET /government/constitution': 'perm:board.vote.cast',
|
|
257
263
|
};
|
|
258
264
|
|
|
259
265
|
// 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
|
@@ -891,8 +891,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
891
891
|
// landed since 1.19.230 with no explicit bump. run 32133132988. (task 1002620)
|
|
892
892
|
// 1.19.232 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
893
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)
|
|
896
|
+
// 1.19.234 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
897
|
+
// landed since 1.19.233 with no explicit bump. run 32161368847. (task 1002620)
|
|
894
898
|
// ---------------------------------------------------------------------------
|
|
895
|
-
const CORE_VERSION = '1.19.
|
|
899
|
+
const CORE_VERSION = '1.19.234'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
896
900
|
|
|
897
901
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
898
902
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// tests/government_constitution_view.mjs — the Constitution section (BV1.R18 of
|
|
2
|
+
// goal 1000069, criteria C3/C5): what we run, and how it changed.
|
|
3
|
+
//
|
|
4
|
+
// DB-free (db monkeypatched). The invariants:
|
|
5
|
+
// 1. THE LIVE VALUE IS RESOLVED, NEVER HARDCODED — the view calls R05's
|
|
6
|
+
// config resolution per request, so an amendment applied a moment ago
|
|
7
|
+
// reads back immediately, and this vanilla checkout reads as monarchy.
|
|
8
|
+
// 2. THE HISTORY SHOWS RETURNED PROPOSALS TOO — a failed attempt to change
|
|
9
|
+
// the rules is part of the record ("a switch can never be flipped
|
|
10
|
+
// quietly" cuts both ways), each row carrying the rule it was DECIDED
|
|
11
|
+
// UNDER so every change is auditable against the constitution of its day.
|
|
12
|
+
// 3. THE READ RIDES THE VOTE ATOM — reading the rule you vote under is part
|
|
13
|
+
// of casting; never the manage atom.
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
24
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
25
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
26
|
+
const { BOARD_DEFAULTS } = require(path.join(GOV, 'config.js'));
|
|
27
|
+
|
|
28
|
+
async function withDb(stubs, fn) {
|
|
29
|
+
const originals = {};
|
|
30
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
|
|
31
|
+
try { return await fn(); }
|
|
32
|
+
finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('the live board block is RESOLVED per request — this vanilla checkout reads monarchy', async () => {
|
|
36
|
+
const view = await withDb({ listAmendmentHistory: async () => [] }, () => board.constitutionView());
|
|
37
|
+
assert.deepEqual(view.board, BOARD_DEFAULTS,
|
|
38
|
+
'no instance pack here, so the resolved value IS the neutral monarchy — never a hardcoded copy');
|
|
39
|
+
assert.deepEqual(view.history, []);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the history projects passed AND returned, with proposer identity and the decided-under snapshot', async () => {
|
|
43
|
+
const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
|
|
44
|
+
const view = await withDb({
|
|
45
|
+
listAmendmentHistory: async (limit) => {
|
|
46
|
+
assert.equal(limit, 50);
|
|
47
|
+
return [
|
|
48
|
+
{ item_id: 30n, amendment_id: 5n, proposed: { pass_rule: 'consent' }, rationale_md: 'open up',
|
|
49
|
+
proposed_by: 42n, proposer_login: 'sole-archon', proposer_name: 'Sole Archon',
|
|
50
|
+
opened_at: 'T1', closed_at: 'T2', outcome: 'passed', decided_under: MONARCHY },
|
|
51
|
+
{ item_id: 29, amendment_id: 4, proposed: { window_minutes: 5 }, rationale_md: null,
|
|
52
|
+
proposed_by: null, proposer_login: null, proposer_name: null,
|
|
53
|
+
opened_at: 'T0', closed_at: 'T1', outcome: 'returned', decided_under: MONARCHY },
|
|
54
|
+
];
|
|
55
|
+
},
|
|
56
|
+
}, () => board.constitutionView());
|
|
57
|
+
assert.equal(view.history.length, 2);
|
|
58
|
+
const [passed, returned] = view.history;
|
|
59
|
+
assert.equal(passed.outcome, 'passed');
|
|
60
|
+
assert.equal(passed.proposer_login, 'sole-archon');
|
|
61
|
+
assert.deepEqual(passed.decided_under, MONARCHY, 'auditable against the constitution of its day (§7 chain)');
|
|
62
|
+
assert.equal(returned.outcome, 'returned', 'a failed attempt to change the rules stays on the record');
|
|
63
|
+
assert.equal(returned.proposed_by, null, 'a departed proposer projects null, honestly');
|
|
64
|
+
for (const h of view.history) {
|
|
65
|
+
assert.equal(typeof h.item_id, 'string');
|
|
66
|
+
assert.equal(typeof h.amendment_id, 'string');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('the history read includes returned outcomes and is bounded + close-ordered (SQL pins)', () => {
|
|
71
|
+
const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
|
|
72
|
+
const block = src.slice(src.indexOf('async function listAmendmentHistory'), src.indexOf('// --- amendments'));
|
|
73
|
+
assert.ok(!block.includes("outcome = 'passed'"), 'returned proposals are part of the record — no outcome filter');
|
|
74
|
+
assert.match(block, /closed_at IS NOT NULL/, 'closed sittings only — an open proposal is the Board Room tab, not history');
|
|
75
|
+
assert.match(block, /ORDER BY i\.closed_at DESC/, 'newest change first');
|
|
76
|
+
assert.match(block, /LIMIT \$1/, 'bounded');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the read rides the vote atom, and the client renders the SERVER value (source pins)', () => {
|
|
80
|
+
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
81
|
+
assert.equal(rrc.MODULE_EXPECTED_RANKS.government['GET /government/constitution'],
|
|
82
|
+
`${rrc.PERM_PIN_PREFIX}board.vote.cast`);
|
|
83
|
+
const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
84
|
+
assert.match(client, /fetchJson\('\/government\/constitution'\)/, 'the panel loads the resolved value');
|
|
85
|
+
assert.ok(!/'first_ratifier'[^]*hardcod/i.test(client), 'sanity');
|
|
86
|
+
assert.match(client, /loadConstitution\(\)/, 'the panel loads for everyone the page admits');
|
|
87
|
+
});
|
|
@@ -89,8 +89,11 @@ test('every /governance route gates via REAL middleware on exactly its PINNED at
|
|
|
89
89
|
assert.equal(r.rank, FLOOR_LABEL[floor], `${r.key}: classifies to ${atom}'s floor label (${FLOOR_LABEL[floor]})`);
|
|
90
90
|
}
|
|
91
91
|
// The admin surface's own rule survives the generalization, stated directly:
|
|
92
|
-
// everything
|
|
93
|
-
|
|
92
|
+
// everything outside the Board Room family — the /board/ routes and the
|
|
93
|
+
// /constitution read (R18: reading the rule you vote under is part of
|
|
94
|
+
// casting) — still gates on government.manage.
|
|
95
|
+
const boardFamily = (key) => key.includes('/board/') || key.endsWith('/government/constitution');
|
|
96
|
+
for (const r of classified.filter((x) => !boardFamily(x.key))) {
|
|
94
97
|
assert.deepEqual(r.perms, ['government.manage'], `${r.key}: the admin surface gates on the manage atom`);
|
|
95
98
|
}
|
|
96
99
|
});
|
|
@@ -40,6 +40,7 @@ const NON_GRANT_MIGRATIONS = new Set([
|
|
|
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
42
|
'government_007_amendments.sql', // R14 amendment proposals table (task 1003027)
|
|
43
|
+
'government_008_closed_items_index.sql', // R16 closed-history index (task 1003029)
|
|
43
44
|
]);
|
|
44
45
|
|
|
45
46
|
// Permission keys RENAMED after the grant migrations that first granted them.
|