@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.
Files changed (36) hide show
  1. package/.bongos-core.json +70 -35
  2. package/docs/copy-inventory.md +166 -158
  3. package/docs/copy-registry.json +286 -192
  4. package/docs/module-api-changelog.md +4 -0
  5. package/modules/builder-settings/builder-needs.js +41 -4
  6. package/modules/economy/credits.js +38 -0
  7. package/modules/economy/reward.js +5 -0
  8. package/modules/hall-ui/public/board-room.css +83 -0
  9. package/modules/hall-ui/public/board-room.html +70 -0
  10. package/modules/hall-ui/public/board-room.js +517 -0
  11. package/modules/hall-ui/public/board-room.states.json +104 -0
  12. package/modules/hall-ui/public/government.css +10 -57
  13. package/modules/hall-ui/public/government.html +10 -12
  14. package/modules/hall-ui/public/government.js +119 -367
  15. package/modules/hall-ui/public/hall-render.js +26 -7
  16. package/modules/hall-ui/public/palette.js +5 -2
  17. package/modules/hall-ui/public/shell.js +16 -0
  18. package/modules/sessions/db.js +65 -0
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/scripts/gds/backfill-session-earnings.js +123 -0
  22. package/scripts/gds/fitness-checks-vocabulary.js +7 -0
  23. package/scripts/gds/run-unit-tests.js +7 -0
  24. package/src/bongos/auth.js +27 -0
  25. package/src/bongos/serve-internal.js +21 -0
  26. package/src/module-api.js +1 -1
  27. package/tests/auth_resolve_failure.mjs +6 -5
  28. package/tests/builder_needs.mjs +122 -0
  29. package/tests/government_board_hash_redirect.mjs +129 -0
  30. package/tests/government_board_room_copy.mjs +41 -15
  31. package/tests/government_board_vote_ui.mjs +5 -1
  32. package/tests/hall_nav.mjs +5 -1
  33. package/tests/hall_page_gate_map.mjs +11 -0
  34. package/tests/hall_palette.mjs +9 -3
  35. package/tests/rank_tier_single_source.mjs +8 -0
  36. 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 (the R15 three-section IA)
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' },
@@ -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.622",
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.622",
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.622",
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
@@ -401,6 +401,21 @@ const PAGE_GATES = Object.freeze({
401
401
  requireProjectCuratePage: Object.freeze({
402
402
  permission: 'project.curate', deniedTo: '/builders', alsoAdmits: 'isProjectOwner',
403
403
  }),
404
+ // The /board-room page (ADR 0266) — gated on `board.vote.cast`, the SAME atom
405
+ // the vote route checks, so the surface's reach equals the right to vote.
406
+ //
407
+ // That atom floors at XENOS on purpose (modules/government/board.js — low so
408
+ // that WIDENING the board works), which makes this the widest page gate in the
409
+ // table. It is deliberate and it is not a hole: the floor is the coarse gate,
410
+ // and MEMBERSHIP is still checked in-handler per item against that item's own
411
+ // snapshotted constitution. A holder who sits on no board sees the room and no
412
+ // ballot — the honest state, since ADR 0175 §9 made the ballot open on purpose.
413
+ //
414
+ // Reaching for `page.view.government` (metic+) instead would look tighter and
415
+ // be wrong: an instance that widens membership below Metic would then have
416
+ // members who can cast a vote through the API and cannot load the page they
417
+ // would cast it on. That is the code-path change ADR 0175 §6 exists to remove.
418
+ requireBoardVotePage: Object.freeze({ permission: 'board.vote.cast', deniedTo: '/builders' }),
404
419
  });
405
420
 
406
421
  // The `alsoAdmits` door — "is this visitor the page's OTHER audience?", asked of
@@ -550,6 +565,17 @@ async function requireProjectCuratePage(req, res, next) {
550
565
  return runPageGate('requireProjectCuratePage', req, res, next);
551
566
  }
552
567
 
568
+ // The /board-room page (ADR 0266) — gated on the `board.vote.cast` ATOM, the
569
+ // same key the vote route checks, so the page layer can never drift NARROWER
570
+ // than the API it fronts. That is the direction that matters here: the Board
571
+ // Room used to live inside /government behind `page.view.government` (metic+)
572
+ // while the vote atom floors at xenos, so a widened board produced members who
573
+ // could vote by API and not load the page. See PAGE_GATES above for why the
574
+ // wide floor is correct rather than a hole (membership is the in-handler check).
575
+ async function requireBoardVotePage(req, res, next) {
576
+ return runPageGate('requireBoardVotePage', req, res, next);
577
+ }
578
+
553
579
  // The orientation reading rooms (the citizen's primer, the system-maps/diagrams)
554
580
  // that #766 closed to newcomers and #770 enforces at the URL. A Xenos's path is
555
581
  // action-only — find work, claim, ship three — so these pages are reserved for
@@ -821,6 +847,7 @@ module.exports = {
821
847
  requireGovernmentManagePage,
822
848
  requireNonXenosPage,
823
849
  requireProjectCuratePage,
850
+ requireBoardVotePage,
824
851
  requireRank,
825
852
  rankMeetsThreshold,
826
853
  requirePermission,
@@ -844,6 +844,21 @@ function mountInternalSurfaces(app) {
844
844
  // the `government.manage` atom itself (archon), the same key its data routes
845
845
  // check — requireGovernmentManagePage, not the metic-floored gate above.
846
846
  const GOVERNMENT_PAGE_RE = /^\/builders\/government(?:\.html)?\/?$/;
847
+ // /board-room — the Board Room, carved out of the /government tab strip onto
848
+ // its own page (ADR 0266). It does NOT take the gate above. Its atom is
849
+ // `board.vote.cast`, the same key the vote route checks, so the page's reach
850
+ // equals the right to vote: that atom floors at xenos on purpose, so that a
851
+ // constitution widening membership below Metic gets members who can actually
852
+ // reach the room rather than members who can only vote by curl. Membership is
853
+ // still the in-handler check, per item, against that item's own snapshotted
854
+ // constitution.
855
+ //
856
+ // NB as at PROJECT_SETTINGS_PAGE_RE: this assignment must stay
857
+ // `const NAME_RE = /…/;` with nothing between the `=` and the literal —
858
+ // tests/hall_page_gate_map.mjs rebuilds the URL→gate map by parsing this file
859
+ // statically, and a comment in that gap drops the regex out of the map, which
860
+ // makes the page test as UNGATED while looking gated here. Prose goes above.
861
+ const BOARD_ROOM_PAGE_RE = /^\/builders\/board-room(?:\.html)?\/?$/;
847
862
  // The /people builder directory (task 1002577): gated on `project.curate`, the
848
863
  // exact permission its data route (GET /scouting) checks — same posture as the
849
864
  // /government tab above (the page can never drift wider than the API it fronts).
@@ -885,6 +900,12 @@ function mountInternalSurfaces(app) {
885
900
  if (GOVERNMENT_PAGE_RE.test(norm)) {
886
901
  return gdsAuth.requireGovernmentPage(req, res, next);
887
902
  }
903
+ // ADR 0266: the Board Room's own page, on the vote atom rather than the
904
+ // government page floor. Ordered after the branch above only for reading
905
+ // order — the regexes are disjoint, so the sequence carries no meaning.
906
+ if (BOARD_ROOM_PAGE_RE.test(norm)) {
907
+ return gdsAuth.requireBoardVotePage(req, res, next);
908
+ }
888
909
  if (PEOPLE_PAGE_RE.test(norm)) {
889
910
  return gdsAuth.requireProjectCuratePage(req, res, next);
890
911
  }
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.622'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.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 > all five delegate to runPageGate, guarded by BV1.R107.
98
- // requireNonXenosPage / (the R113 roles-tab gate; the /people curate gate)
99
- // requireProjectCuratePage /
97
+ // requireGovernmentManagePage \ all SIX delegate to runPageGate, guarded by BV1.R107.
98
+ // requireNonXenosPage / (the R113 roles-tab gate; the /people curate
99
+ // requireProjectCuratePage / gate; the ADR 0266 /board-room vote gate)
100
+ // requireBoardVotePage /
100
101
  // Anything else async and router-mounted must be added with its own try/catch.
101
102
  const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
102
103
  const asyncMw = [...src.matchAll(/^async function (require\w+|allow\w+)\s*\(req, res, next\)/gm)].map((m) => m[1]);
103
- assert.deepEqual(asyncMw.sort(), ['requireBuilder', 'requireBuilderPage', 'requireGovernmentManagePage', 'requireGovernmentPage', 'requireNonXenosPage', 'requireProjectCuratePage'],
104
+ assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireGovernmentManagePage', 'requireGovernmentPage', 'requireNonXenosPage', 'requireProjectCuratePage'],
104
105
  'a new async router middleware appeared — give it a try/catch and add it here');
105
106
  // The page gates must actually delegate (that is WHY they are safe).
106
- for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireGovernmentManagePage', 'requireNonXenosPage', 'requireProjectCuratePage']) {
107
+ for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireGovernmentManagePage', 'requireNonXenosPage', 'requireProjectCuratePage', 'requireBoardVotePage']) {
107
108
  const fn = src.slice(src.indexOf(`async function ${name}`));
108
109
  assert.match(sliceFn(fn), /runPageGate\(/, `${name} must delegate to the guarded runPageGate`);
109
110
  }
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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);