@bongos/core 1.19.622 → 1.19.624
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 +70 -35
- package/docs/copy-inventory.md +166 -158
- package/docs/copy-registry.json +286 -192
- package/docs/module-api-changelog.md +4 -0
- package/modules/builder-settings/builder-needs.js +41 -4
- package/modules/economy/credits.js +38 -0
- package/modules/economy/reward.js +5 -0
- package/modules/hall-ui/public/board-room.css +83 -0
- package/modules/hall-ui/public/board-room.html +70 -0
- package/modules/hall-ui/public/board-room.js +517 -0
- package/modules/hall-ui/public/board-room.states.json +104 -0
- package/modules/hall-ui/public/government.css +10 -57
- package/modules/hall-ui/public/government.html +10 -12
- package/modules/hall-ui/public/government.js +119 -367
- package/modules/hall-ui/public/hall-render.js +26 -7
- package/modules/hall-ui/public/palette.js +5 -2
- package/modules/hall-ui/public/shell.js +16 -0
- package/modules/sessions/db.js +65 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-session-earnings.js +123 -0
- package/scripts/gds/fitness-checks-vocabulary.js +7 -0
- package/scripts/gds/run-unit-tests.js +7 -0
- package/src/bongos/auth.js +27 -0
- package/src/bongos/serve-internal.js +21 -0
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +6 -5
- package/tests/builder_needs.mjs +122 -0
- package/tests/government_board_hash_redirect.mjs +129 -0
- package/tests/government_board_room_copy.mjs +41 -15
- package/tests/government_board_vote_ui.mjs +5 -1
- package/tests/hall_nav.mjs +5 -1
- package/tests/hall_page_gate_map.mjs +11 -0
- package/tests/hall_palette.mjs +9 -3
- package/tests/rank_tier_single_source.mjs +8 -0
- package/tests/session_earnings_mirror_db.mjs +250 -0
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// Anchors must stay in step with their source of truth; the test asserts it:
|
|
31
31
|
// standing → builders.js STANDING_GROUPS (+ shell.js VIEW_ALIASES)
|
|
32
32
|
// work → work.js makeTabs ids
|
|
33
|
-
// government → government.js makeTabs ids (
|
|
33
|
+
// government → government.js makeTabs ids (two rooms since ADR 0266)
|
|
34
34
|
// settings → settings.js RAIL_GROUPS
|
|
35
35
|
var SECTIONS = [
|
|
36
36
|
{ parent: 'Standing', gate: 'nav-standing', items: [
|
|
@@ -45,9 +45,12 @@
|
|
|
45
45
|
{ label: 'Backlog', href: '/work#backlog' },
|
|
46
46
|
{ label: 'Completed', href: '/work#completed' },
|
|
47
47
|
] },
|
|
48
|
+
// Two entries, not three: the Board Room is a PAGE since ADR 0266, so it is
|
|
49
|
+
// read live from the sidebar like every other page and must not be declared
|
|
50
|
+
// here as well — a sub-destination and a page of the same name would offer
|
|
51
|
+
// the reader two rows to the same room.
|
|
48
52
|
{ parent: 'Government', gate: 'nav-government', items: [
|
|
49
53
|
{ label: 'Constitution', href: '/government#constitution' },
|
|
50
|
-
{ label: 'Board Room', href: '/government#board-room' },
|
|
51
54
|
{ label: 'Ranks & Permissions', href: '/government#ranks' },
|
|
52
55
|
] },
|
|
53
56
|
{ parent: 'Settings', gate: 'shell-user', items: [
|
|
@@ -246,6 +246,22 @@
|
|
|
246
246
|
// Archon-only, so a group header reading "Archon" over items a Metic can open
|
|
247
247
|
// would misdescribe the ladder to the builders most likely to be learning it.
|
|
248
248
|
{ group: 'Government', gate: 'government', items: [
|
|
249
|
+
// FIRST in the group because it is the one page here you ACT on — the
|
|
250
|
+
// Community group's ordering rule, applied (a sitting waits on a decision;
|
|
251
|
+
// the rest of this group is monitoring). Carved out of the /government tab
|
|
252
|
+
// strip by ADR 0266, which is also why it needs its own item at all: while
|
|
253
|
+
// it was a tab, the group's only door to it was labelled "Permissions".
|
|
254
|
+
//
|
|
255
|
+
// THE GATE HERE IS COSMETIC AND DELIBERATELY CONSERVATIVE. The page's real
|
|
256
|
+
// wall is `board.vote.cast` (auth.js requireBoardVotePage), which floors at
|
|
257
|
+
// xenos so a widened board can reach the room. This nav gate cannot say
|
|
258
|
+
// that: gateAllows() speaks rank, not permission. It is set to the group's
|
|
259
|
+
// floor because today's constitution seats `rank:archon`, so no sub-Metic
|
|
260
|
+
// member exists to hide the link from. An instance that widens membership
|
|
261
|
+
// below Metic gets a room it can reach by URL and by the board_votes need's
|
|
262
|
+
// deep link, but no sidebar entry — teaching the shell a permission-aware
|
|
263
|
+
// gate is filed rather than guessed at here.
|
|
264
|
+
{ id: 'board-room', label: 'Board Room', icon: 'admin', href: '/board-room', gate: 'government' },
|
|
249
265
|
{ id: 'watch', label: 'Watch', icon: 'admin', href: '/watch', gate: 'government' },
|
|
250
266
|
{ id: 'harbor', label: 'Harbor', icon: 'admin', href: '/harbor', gate: 'government', module: 'dev-box' },
|
|
251
267
|
{ id: 'gate', label: 'Gate', icon: 'admin', href: '/gate', gate: 'government' },
|
package/modules/sessions/db.js
CHANGED
|
@@ -109,6 +109,52 @@ async function upsertSessionRecord(opts) {
|
|
|
109
109
|
return upsertSessionRecordTx(pool, opts);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// syncSessionDrachmaeEarnedTx — re-derive session_records.drachmae_earned from
|
|
113
|
+
// the ledger (task 1003634). Returns the synced value, or null if there was no
|
|
114
|
+
// row to sync (or economy is disabled).
|
|
115
|
+
//
|
|
116
|
+
// The column means "the credit_log delta attributable to this session"
|
|
117
|
+
// (migration 062), but the only thing that ever wrote it was the route's
|
|
118
|
+
// sumCreditsForTasks(), which sums rows keyed on `task_id`. The cost-plus session
|
|
119
|
+
// reward keys on `session_id` and leaves task_id NULL deliberately, because it
|
|
120
|
+
// pays for the session's real token spend rather than for any one task in it. So
|
|
121
|
+
// the mirror could not see it. Under reward mode 'cost-plus-only' (ADR 0146 —
|
|
122
|
+
// this instance's standing choice) that reward is the ONLY stream that pays, so
|
|
123
|
+
// the figure was structurally 0 for every session, and the hall told builders
|
|
124
|
+
// they had earned nothing for work they were genuinely paid for. A builder
|
|
125
|
+
// reported it as missing pay; the pay was never missing, this number was.
|
|
126
|
+
//
|
|
127
|
+
// It asks economy for the total over BOTH attribution keys and SETS it, rather
|
|
128
|
+
// than adding a delta. That is what makes it idempotent and safe to call from
|
|
129
|
+
// every trigger: the sum covers the row's ACCUMULATED task_ids (set-unioned
|
|
130
|
+
// across every upload the session has made) plus its session_id, so it already
|
|
131
|
+
// describes the whole session however many times it runs. Re-running can only
|
|
132
|
+
// land on the same number, which is why calling it on an already-paid session is
|
|
133
|
+
// a repair rather than a double-count.
|
|
134
|
+
async function syncSessionDrachmaeEarnedTx(client, builderId, sessionId) {
|
|
135
|
+
const reward = seams.resolveOptional('reward');
|
|
136
|
+
if (!reward || !sessionId) return null;
|
|
137
|
+
// The row's task_ids are read back rather than taken from the caller: the
|
|
138
|
+
// upsert set-unions them, and the two deferred triggers never had them at all.
|
|
139
|
+
const { rows } = await client.query(
|
|
140
|
+
`SELECT task_ids FROM session_records WHERE session_id = $1 AND builder_id = $2`,
|
|
141
|
+
[sessionId, builderId]
|
|
142
|
+
);
|
|
143
|
+
if (rows.length === 0) return null;
|
|
144
|
+
const ids = Array.isArray(rows[0].task_ids)
|
|
145
|
+
? rows[0].task_ids.map((n) => Number(n)).filter(Number.isFinite)
|
|
146
|
+
: [];
|
|
147
|
+
const earned = await reward.sumSessionEarnings(client, builderId, ids, sessionId);
|
|
148
|
+
const { rows: synced } = await client.query(
|
|
149
|
+
`UPDATE session_records
|
|
150
|
+
SET drachmae_earned = $3
|
|
151
|
+
WHERE session_id = $1 AND builder_id = $2
|
|
152
|
+
RETURNING drachmae_earned`,
|
|
153
|
+
[sessionId, builderId, Math.trunc(Number(earned) || 0)]
|
|
154
|
+
);
|
|
155
|
+
return synced.length > 0 ? synced[0].drachmae_earned : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
112
158
|
// awardSessionTokenRewardTx — resolve the economy `reward` port and book the
|
|
113
159
|
// idempotent cost-plus session token reward on `client`, mirroring the granted
|
|
114
160
|
// drachmae onto the session_records row for observability (the authoritative
|
|
@@ -137,6 +183,19 @@ async function awardSessionTokenRewardTx(client, { builderId, sessionId, modelUs
|
|
|
137
183
|
[sessionId, tokenReward.drachmae, builderId]
|
|
138
184
|
);
|
|
139
185
|
}
|
|
186
|
+
// Sync the earnings mirror on EVERY attempt, not only a fresh award. The
|
|
187
|
+
// watermark makes a re-attempt on an already-paid session a clean no-op for the
|
|
188
|
+
// money, and this is where that no-op earns its keep: it still repairs a row
|
|
189
|
+
// whose drachmae_earned was written by the old task_id-only sum. Since all
|
|
190
|
+
// three pay-on-land triggers funnel through here, one call site covers the
|
|
191
|
+
// upload-time gate, the task.shipped listener, and the state-based sweep.
|
|
192
|
+
const sessionDrachmaeEarned = await syncSessionDrachmaeEarnedTx(client, builderId, sessionId);
|
|
193
|
+
// Ride the synced figure back on the result so a caller holding a pre-sync
|
|
194
|
+
// record can refresh it — otherwise POST /sessions echoes the stale 0 it was
|
|
195
|
+
// handed even though the row is now correct.
|
|
196
|
+
if (tokenReward && sessionDrachmaeEarned !== null) {
|
|
197
|
+
tokenReward.sessionDrachmaeEarned = sessionDrachmaeEarned;
|
|
198
|
+
}
|
|
140
199
|
return tokenReward;
|
|
141
200
|
}
|
|
142
201
|
|
|
@@ -186,6 +245,12 @@ async function upsertSessionRecordWithReward(opts) {
|
|
|
186
245
|
totalTokens: opts.totalTokens || 0,
|
|
187
246
|
});
|
|
188
247
|
}
|
|
248
|
+
// The upsert wrote the figure the route computed BEFORE this transaction
|
|
249
|
+
// booked anything; the award has since re-derived it. Adopt it so the
|
|
250
|
+
// response matches the row.
|
|
251
|
+
if (tokenReward && tokenReward.sessionDrachmaeEarned != null) {
|
|
252
|
+
record.drachmae_earned = tokenReward.sessionDrachmaeEarned;
|
|
253
|
+
}
|
|
189
254
|
}
|
|
190
255
|
return record ? { ...record, tokenReward } : null;
|
|
191
256
|
});
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.624",
|
|
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.624",
|
|
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.624",
|
|
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",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/backfill-session-earnings.js — repair the historical
|
|
5
|
+
// `session_records.drachmae_earned` mirror (task 1003634).
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS IS, and what it emphatically is NOT. It moves NO money. It writes no
|
|
8
|
+
// `credit_log` row, mints nothing, and cannot change any builder's balance. It
|
|
9
|
+
// rewrites ONE display column to agree with the ledger that already exists. That
|
|
10
|
+
// makes it categorically unlike its neighbours here (backfill-task-rewards.js,
|
|
11
|
+
// backfill-subagent-rewards.js), which mint credits and carry an ADR 0097 /
|
|
12
|
+
// ADR 0173 safety model to match. Read this one as a data repair.
|
|
13
|
+
//
|
|
14
|
+
// WHY IT IS NEEDED. `drachmae_earned` means "the credit_log delta attributable to
|
|
15
|
+
// this session" (migration 062), but the only thing that ever wrote it summed
|
|
16
|
+
// rows keyed on `task_id`. The cost-plus session reward (ADR 0054) keys on
|
|
17
|
+
// `session_id` and leaves `task_id` NULL on purpose, because it pays for the
|
|
18
|
+
// session's real token spend rather than for any one task in it. So the mirror
|
|
19
|
+
// could not see it. Under reward mode `cost-plus-only` (ADR 0146 — this
|
|
20
|
+
// instance's standing choice) that reward is the ONLY stream that pays, so the
|
|
21
|
+
// column read 0 for essentially every session, and the hall told builders they
|
|
22
|
+
// had earned nothing for work they had genuinely been paid for. A builder
|
|
23
|
+
// reported it as missing pay; the pay was never missing, this number was.
|
|
24
|
+
//
|
|
25
|
+
// WHY THE LIVE FIX DOES NOT COVER HISTORY. The code fix re-derives the figure
|
|
26
|
+
// whenever a reward books, and all three pay-on-land triggers now funnel through
|
|
27
|
+
// that. But none of them revisits a session that is already paid in full — the
|
|
28
|
+
// state-based sweep explicitly skips any session that already has a
|
|
29
|
+
// `session.token_reward` row. So already-paid history never self-heals, which is
|
|
30
|
+
// precisely the history a builder looks at. Hence a one-shot pass.
|
|
31
|
+
//
|
|
32
|
+
// SAFETY. DRY-RUN BY DEFAULT: with no flags it prints the plan and writes
|
|
33
|
+
// nothing. `--apply` performs the UPDATEs. Idempotent by construction — every
|
|
34
|
+
// row is SET to a value derived wholly from current ledger state, so re-running
|
|
35
|
+
// lands on the same numbers, and a row already correct is skipped and reported
|
|
36
|
+
// as such. `--builder N` scopes the pass to one builder (useful for verifying on
|
|
37
|
+
// a single account before the full sweep).
|
|
38
|
+
//
|
|
39
|
+
// Run: node scripts/gds/backfill-session-earnings.js [--builder N] [--limit N]
|
|
40
|
+
// node scripts/gds/backfill-session-earnings.js --apply
|
|
41
|
+
|
|
42
|
+
const path = require('node:path');
|
|
43
|
+
|
|
44
|
+
const credits = require('../../modules/economy/credits');
|
|
45
|
+
const { arg, hasFlag } = require('./cli-lib');
|
|
46
|
+
|
|
47
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
48
|
+
|
|
49
|
+
function loadPool() {
|
|
50
|
+
const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
|
|
51
|
+
return pool;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function main() {
|
|
55
|
+
const apply = hasFlag('--apply');
|
|
56
|
+
const builderArg = arg('--builder');
|
|
57
|
+
const limitArg = Number(arg('--limit'));
|
|
58
|
+
const limit = Number.isFinite(limitArg) && limitArg > 0 ? Math.trunc(limitArg) : null;
|
|
59
|
+
|
|
60
|
+
const pool = loadPool();
|
|
61
|
+
const client = await pool.connect();
|
|
62
|
+
let changed = 0;
|
|
63
|
+
let alreadyCorrect = 0;
|
|
64
|
+
let scanned = 0;
|
|
65
|
+
let deltaTotal = 0;
|
|
66
|
+
const examples = [];
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const params = [];
|
|
70
|
+
let where = 'WHERE sr.session_id IS NOT NULL';
|
|
71
|
+
if (builderArg) { params.push(builderArg); where += ` AND sr.builder_id = $${params.length}`; }
|
|
72
|
+
let sql = `SELECT sr.session_id, sr.builder_id, sr.task_ids, sr.drachmae_earned,
|
|
73
|
+
b.github_login
|
|
74
|
+
FROM session_records sr
|
|
75
|
+
JOIN builders b ON b.id = sr.builder_id
|
|
76
|
+
${where}
|
|
77
|
+
ORDER BY sr.uploaded_at DESC`;
|
|
78
|
+
if (limit) { params.push(limit); sql += ` LIMIT $${params.length}`; }
|
|
79
|
+
|
|
80
|
+
const { rows } = await client.query(sql, params);
|
|
81
|
+
scanned = rows.length;
|
|
82
|
+
|
|
83
|
+
for (const r of rows) {
|
|
84
|
+
const ids = Array.isArray(r.task_ids)
|
|
85
|
+
? r.task_ids.map((n) => Number(n)).filter(Number.isFinite)
|
|
86
|
+
: [];
|
|
87
|
+
// The same two-key sum the live path uses — one definition, so a repaired
|
|
88
|
+
// row and a freshly-synced row can never disagree.
|
|
89
|
+
const truth = await credits.sumSessionEarnings(client, r.builder_id, ids, r.session_id);
|
|
90
|
+
const was = Number(r.drachmae_earned) || 0;
|
|
91
|
+
if (truth === was) { alreadyCorrect++; continue; }
|
|
92
|
+
changed++;
|
|
93
|
+
deltaTotal += (truth - was);
|
|
94
|
+
if (examples.length < 15) {
|
|
95
|
+
examples.push(` ${String(r.github_login).padEnd(18)} ${r.session_id.slice(0, 8)} ${was} → ${truth}`);
|
|
96
|
+
}
|
|
97
|
+
if (apply) {
|
|
98
|
+
await client.query(
|
|
99
|
+
`UPDATE session_records SET drachmae_earned = $3
|
|
100
|
+
WHERE session_id = $1 AND builder_id = $2`,
|
|
101
|
+
[r.session_id, r.builder_id, truth]
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
client.release();
|
|
107
|
+
await pool.end().catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log(`\nsession-earnings backfill — ${apply ? 'APPLIED' : 'DRY RUN (no writes)'}`);
|
|
111
|
+
console.log(` sessions scanned ${scanned}`);
|
|
112
|
+
console.log(` already correct ${alreadyCorrect}`);
|
|
113
|
+
console.log(` ${apply ? 'repaired' : 'would repair'}${apply ? ' ' : ' '}${changed}`);
|
|
114
|
+
console.log(` net mirror change ${deltaTotal >= 0 ? '+' : ''}${deltaTotal} drachmae of DISPLAY (no money moved)`);
|
|
115
|
+
if (examples.length > 0) {
|
|
116
|
+
console.log(`\n sample rows:\n${examples.join('\n')}`);
|
|
117
|
+
}
|
|
118
|
+
if (!apply && changed > 0) {
|
|
119
|
+
console.log(`\n re-run with --apply to write these.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
|
@@ -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 = [
|
|
@@ -106,6 +106,13 @@ const INTEGRATION = new Set([
|
|
|
106
106
|
// parses this set out of the source, so a quoted word in a comment reads as an
|
|
107
107
|
// entry — the same reason the copy_desk_flags_db note above is worded that way.)
|
|
108
108
|
'idea_credit_streams_db',
|
|
109
|
+
// task 1003634: the session earnings mirror against real SQL. The defect was
|
|
110
|
+
// never arithmetic — it was which credit_log rows a WHERE clause can reach, so
|
|
111
|
+
// a stubbed client would answer exactly the assumption under test. Here the
|
|
112
|
+
// two-key predicate meets a real planner, and the first test asserts the OLD
|
|
113
|
+
// predicate still returns 0 so the bug itself stays executable. Same
|
|
114
|
+
// always-rolled-back transaction discipline as the entry above.
|
|
115
|
+
'session_earnings_mirror_db',
|
|
109
116
|
// RENAMED by ADR 0174 (goal 1000068) — these entries said governance_* until task
|
|
110
117
|
// 1003091. A curated set keyed by a stem that no longer exists silently stops
|
|
111
118
|
// covering its file: both of these dropped OUT of the integration set and INTO the
|
package/src/bongos/auth.js
CHANGED
|
@@ -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.
|
|
58
|
+
const CORE_VERSION = '1.19.624'; // 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
|
|
98
|
-
// requireNonXenosPage
|
|
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
|
}
|
package/tests/builder_needs.mjs
CHANGED
|
@@ -209,5 +209,127 @@ t('the /me aggregator feeds computeNeeds the founder facts under their contract
|
|
|
209
209
|
assert.match(src, /getFoundingBuilder\(\)/, 'me.js must resolve the founder row');
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
// ---- two needs at once (task 1003738) --------------------------------------
|
|
213
|
+
//
|
|
214
|
+
// The defect: computeNeeds sorted by state alone with a stable sort, so within
|
|
215
|
+
// the action_needed bucket the NEEDS array order decided everything — and
|
|
216
|
+
// board_votes is listed fourth, behind art_key. A builder with an unset image
|
|
217
|
+
// key therefore never saw a waiting board vote, on either surface, because both
|
|
218
|
+
// renderers took "the first one". The fix is two-part and both parts are pinned
|
|
219
|
+
// here: an explicit priority inside the bucket, and a LIST surface that renders
|
|
220
|
+
// every action_needed item instead of only the loudest.
|
|
221
|
+
console.log('two simultaneous action_needed needs (task 1003738):');
|
|
222
|
+
|
|
223
|
+
// art_key action_needed (archon, no own key) + board_votes action_needed.
|
|
224
|
+
const TWO = {
|
|
225
|
+
rank: 'archon',
|
|
226
|
+
ownKeySet: false,
|
|
227
|
+
pendingBoardVotes: { count: 2, first_item_id: 12 },
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
t('both needs are action_needed — the fixture really does collide', () => {
|
|
231
|
+
const r = needs.computeNeeds(TWO);
|
|
232
|
+
const ids = r.items.filter((n) => n.state === 'action_needed').map((n) => n.id);
|
|
233
|
+
assert.ok(ids.includes('art_key'), 'art_key must be action_needed here');
|
|
234
|
+
assert.ok(ids.includes('board_votes'), 'board_votes must be action_needed here');
|
|
235
|
+
assert.equal(r.action_needed_count, 2);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
t('the board vote outranks the art key — someone else is blocked on it', () => {
|
|
239
|
+
const r = needs.computeNeeds(TWO);
|
|
240
|
+
assert.equal(r.items[0].id, 'board_votes',
|
|
241
|
+
'a need others are blocked on must lead; registry order must not decide');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
t('priority is NOT severity — severity would have kept the bug', () => {
|
|
245
|
+
// The trap worth pinning: art_key is severity 'action' and board_votes is
|
|
246
|
+
// 'info', so ordering the bucket by severity would have left the art key
|
|
247
|
+
// first and the board vote hidden. If someone "simplifies" the weight map
|
|
248
|
+
// into a severity sort, this fails.
|
|
249
|
+
const r = needs.computeNeeds(TWO);
|
|
250
|
+
const art_ = r.items.find((n) => n.id === 'art_key');
|
|
251
|
+
const board = r.items.find((n) => n.id === 'board_votes');
|
|
252
|
+
assert.equal(art_.severity, 'action');
|
|
253
|
+
assert.equal(board.severity, 'info');
|
|
254
|
+
assert.ok(needs.NEED_PRIORITY.board_votes < needs.NEED_PRIORITY.art_key,
|
|
255
|
+
'the weight map, not severity, is what orders the bucket');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
t('an unweighted need sorts last in its bucket, never first', () => {
|
|
259
|
+
// Appending to NEEDS without adding a weight must not silently take the lead.
|
|
260
|
+
const items = [{ id: 'brand_new', state: 'action_needed' }, { id: 'board_votes', state: 'action_needed' }];
|
|
261
|
+
const order = { action_needed: 0, covered: 1, satisfied: 2 };
|
|
262
|
+
const weight = (n) => needs.NEED_PRIORITY[n.id] ?? 99;
|
|
263
|
+
items.sort((a, b) => ((order[a.state] ?? 9) - (order[b.state] ?? 9)) || (weight(a) - weight(b)));
|
|
264
|
+
assert.equal(items[0].id, 'board_votes');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
t('the founding welcome still leads during the grace', () => {
|
|
268
|
+
// The weight tie-break must not demote the welcome: before this task it was
|
|
269
|
+
// first only because unshift + a stable sort put it there.
|
|
270
|
+
const r = needs.computeNeeds({
|
|
271
|
+
...TWO, isFoundingOwner: true, founderCreatedAt: new Date().toISOString(), projectName: 'Mercury',
|
|
272
|
+
});
|
|
273
|
+
assert.equal(r.items[0].id, 'founding_welcome');
|
|
274
|
+
assert.equal(r.has_action_needed, false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ---- the rendered output ----------------------------------------------------
|
|
278
|
+
//
|
|
279
|
+
// renderSystemNotices lives inside hall-render.js's IIFE, so it cannot be
|
|
280
|
+
// imported — but it only touches `document`, `escapeHtml` and `window`, so the
|
|
281
|
+
// real shipped function is sliced out and RUN here (the repo's "run it rather
|
|
282
|
+
// than grep it" preference) rather than asserted against by regex. A grep would
|
|
283
|
+
// pass on a comment; this fails if the board vote is not in the HTML.
|
|
284
|
+
function runSystemNotices(args) {
|
|
285
|
+
const src = readFileSync(path.join(ROOT, 'modules/hall-ui/public/hall-render.js'), 'utf8');
|
|
286
|
+
const start = src.indexOf('function renderSystemNotices');
|
|
287
|
+
assert.ok(start > 0, 'renderSystemNotices must still exist in hall-render.js');
|
|
288
|
+
// Anchor on the BODY brace, not the first '{' — the signature destructures
|
|
289
|
+
// its argument, so the first brace closes the parameter list.
|
|
290
|
+
const argsEnd = src.indexOf(') {', start);
|
|
291
|
+
assert.ok(argsEnd > start, 'renderSystemNotices signature changed shape');
|
|
292
|
+
let i = src.indexOf('{', argsEnd);
|
|
293
|
+
let depth = 0;
|
|
294
|
+
let end = -1;
|
|
295
|
+
for (; i < src.length; i++) {
|
|
296
|
+
if (src[i] === '{') depth++;
|
|
297
|
+
else if (src[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
|
|
298
|
+
}
|
|
299
|
+
assert.ok(end > start, 'could not slice renderSystemNotices');
|
|
300
|
+
const box = { innerHTML: '' };
|
|
301
|
+
const doc = { getElementById: (id) => (id === 'attention-system' ? box : null) };
|
|
302
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
303
|
+
const body = src.slice(start, end);
|
|
304
|
+
const make = new Function('document', 'escapeHtml', 'window', `${body}\nreturn renderSystemNotices;`);
|
|
305
|
+
make(doc, esc, {})(args);
|
|
306
|
+
return box.innerHTML;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
t('THE BUG: with the art key also open, the board vote is still rendered', () => {
|
|
310
|
+
const r = needs.computeNeeds(TWO);
|
|
311
|
+
const actionNeeds = r.items.filter((n) => n.state === 'action_needed');
|
|
312
|
+
const html = runSystemNotices({
|
|
313
|
+
rebaseTasks: [], need: actionNeeds[0], actionNeeds, cost: null,
|
|
314
|
+
});
|
|
315
|
+
assert.match(html, /waiting on your vote/i,
|
|
316
|
+
'the board vote must be reachable in the rendered output, not suppressed');
|
|
317
|
+
assert.match(html, /art coverage has ended/i,
|
|
318
|
+
'and the art key must not be dropped either — the list shows both');
|
|
319
|
+
assert.equal((html.match(/<li class="ginbox__item/g) || []).length, 2, 'one line per waiting need');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
t('a single action_needed need still renders exactly one line', () => {
|
|
323
|
+
const r = needs.computeNeeds({ rank: 'thetes', ownKeySet: false });
|
|
324
|
+
const actionNeeds = r.items.filter((n) => n.state === 'action_needed');
|
|
325
|
+
const html = runSystemNotices({ rebaseTasks: [], need: actionNeeds[0], actionNeeds, cost: null });
|
|
326
|
+
assert.equal((html.match(/<li class="ginbox__item/g) || []).length, 1);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
t('nothing waiting renders nothing at all', () => {
|
|
330
|
+
const html = runSystemNotices({ rebaseTasks: [], need: null, actionNeeds: [], cost: null });
|
|
331
|
+
assert.equal(html, '');
|
|
332
|
+
});
|
|
333
|
+
|
|
212
334
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
213
335
|
process.exit(failed ? 1 : 0);
|