@bongos/core 1.21.33 → 1.21.35

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 (37) hide show
  1. package/.bongos-core.json +51 -41
  2. package/docs/copy-inventory.md +19 -17
  3. package/docs/copy-registry.json +39 -21
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +11 -1
  6. package/docs/page-readings.json +322 -194
  7. package/modules/autonomy/governor-docket.js +68 -1
  8. package/modules/government/docket-floors.js +20 -0
  9. package/modules/government/docket-vocab.js +14 -0
  10. package/modules/government/docket.js +103 -2
  11. package/modules/government/routes/government.js +13 -0
  12. package/modules/hall-ui/public/street-card.js +97 -1
  13. package/modules/hall-ui/public/street-desk.js +386 -0
  14. package/modules/hall-ui/public/street-inside.js +14 -10
  15. package/modules/hall-ui/public/street.css +60 -0
  16. package/modules/hall-ui/public/street.html +9 -3
  17. package/modules/hall-ui/public/street.js +238 -17
  18. package/modules/hall-ui/public/street.states.json +314 -6
  19. package/modules/lifecycle/governor-docket.js +24 -2
  20. package/modules/lifecycle/routes/project-settings.js +24 -0
  21. package/modules/onboarding/routes/access-requests.js +7 -0
  22. package/modules/provisioning/core-upgrade.js +1 -1
  23. package/modules/provisioning/routes/core-upgrade.js +36 -1
  24. package/modules/security/routes/reports.js +23 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +12 -0
  28. package/src/bongos/routes/backup.js +26 -0
  29. package/src/bongos/routes/builders.js +40 -0
  30. package/src/bongos/routes/modules.js +18 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/governor_docket.mjs +28 -1
  33. package/tests/hall_matter_card.mjs +12 -3
  34. package/tests/hall_street.mjs +1 -1
  35. package/tests/hall_street_amend.mjs +1 -1
  36. package/tests/hall_street_desk.mjs +557 -0
  37. package/tests/hall_street_inside.mjs +1 -1
@@ -58,10 +58,29 @@ function removalMatter(r) {
58
58
  tally: null,
59
59
  href: `/task/${encodeURIComponent(r.proposal_task_id)}`,
60
60
  ref: { type: 'task', id: r.proposal_task_id },
61
+ // POST /removal-proposals/:id/decide, the docket source's own atom (task 1004559)
62
+ acts: [{ key: 'remove' }, { key: 'keep' }],
61
63
  };
62
64
  }
63
65
 
64
- function uatMatter(c) {
66
+ // The one-tap reasons a force sign-off may give (POST /done-when/:id/satisfy
67
+ // requires one); the desk sends the label as the override reason.
68
+ const FORCE_REASONS = Object.freeze([
69
+ { key: 'tested_by_hand', label: 'tested by hand' },
70
+ { key: 'checked_review', label: 'seen in review' },
71
+ { key: 'no_longer', label: 'no longer applies' },
72
+ ]);
73
+ // What the viewer may do on a criterion waiting for a tester (task 1004559). A
74
+ // backend-only criterion signs off in one tap (with the live attestation as its
75
+ // chip); a user-facing one needs a recording, so its act goes to the goal's page.
76
+ // Force sign-off only for a holder of criterion.satisfy, that route's own atom.
77
+ function uatActs(c, { force = false } = {}) {
78
+ const acts = [c.backend_only === true ? { key: 'signoff', options: [{ key: 'live', label: 'tested it live' }] } : { key: 'record' }];
79
+ if (force) acts.push({ key: 'force', options: FORCE_REASONS });
80
+ return acts;
81
+ }
82
+
83
+ function uatMatter(c, opts) {
65
84
  return {
66
85
  id: c.id,
67
86
  rule: 'signoff',
@@ -75,6 +94,7 @@ function uatMatter(c) {
75
94
  tally: { for: c.shipped_total, against: 0, open: 0, needs: 'one sign-off by someone who did not build it' },
76
95
  href: c.goal_id ? `/goals#/goal/${encodeURIComponent(c.goal_id)}` : '/goals',
77
96
  ref: { type: 'criterion', id: c.id },
97
+ acts: uatActs(c, opts),
78
98
  };
79
99
  }
80
100
 
@@ -326,7 +346,8 @@ function contributeLifecycleToDocket() {
326
346
  source: 'criteria_uat',
327
347
  building: 'review',
328
348
  gate: { permission: 'criterion.review' },
329
- read: async () => (await doneWhen.listPendingReviewCriteria({})).filter((c) => c.awaiting_uat === true).map(uatMatter),
349
+ read: async (ctx) => (await doneWhen.listPendingReviewCriteria({})).filter((c) => c.awaiting_uat === true)
350
+ .map((c) => uatMatter(c, { force: ctx.has('criterion.satisfy') })),
330
351
  });
331
352
  // Own-scoped by its read, like GET /goals/inbox: a goal's lead sees the requests
332
353
  // to join their goals, and everyone sees their own pending ones. An Archon is
@@ -371,6 +392,7 @@ module.exports = {
371
392
  overrideMatter,
372
393
  removalMatter,
373
394
  uatMatter,
395
+ uatActs,
374
396
  joinRequestMatter,
375
397
  flowReading,
376
398
  promisesReading,
@@ -134,7 +134,29 @@ function upmarketRow() {
134
134
  };
135
135
  }
136
136
 
137
+ // The Rules desk's settings rows (task 1004559), under this page's own atom: the
138
+ // rot timer as one-tap day counts (PATCH /project-settings/rot-days), and the
139
+ // work categories as a link to this page, since a category is named in words.
140
+ // Pure; exported for tests.
141
+ const ROT_CHOICES = Object.freeze([7, 14, 30, 60, 90]);
142
+ function settingsDeskRows({ days, categories }) {
143
+ const live = (categories || []).filter((c) => c && c.active !== false).length;
144
+ return [
145
+ { id: 'rot', name: 'Rot timer', fact: `stale after ${days} ${days === 1 ? 'day' : 'days'}`, facts: ['raising it only asks a person'], tool: true, ref: { type: 'project_setting', id: 'rot_days' },
146
+ acts: [{ key: 'rot', options: ROT_CHOICES.filter((d) => d !== Number(days)).slice(0, 4).map((d) => ({ key: String(d), label: `${d} days` })) }] },
147
+ { id: 'categories', name: 'Work categories', fact: `${live} in use`, tool: true, href: '/project-settings', ref: { type: 'project_setting', id: 'work_categories' }, acts: [{ key: 'open' }] },
148
+ ];
149
+ }
150
+ function contributeSettingsDesk() {
151
+ api.contribute('governor.docket', {
152
+ desk: 'rules.settings',
153
+ gate: { permission: 'project.settings.manage' },
154
+ read: async () => settingsDeskRows({ days: (await api.resolveRotDays()).days, categories: await goalCategory.listCategories({ includeInactive: true }) }),
155
+ });
156
+ }
157
+
137
158
  module.exports = function buildProjectSettingsRouter() {
159
+ contributeSettingsDesk();
138
160
  const router = express.Router();
139
161
 
140
162
  // GET /project-settings — all five rows in ONE read.
@@ -261,3 +283,5 @@ module.exports = function buildProjectSettingsRouter() {
261
283
 
262
284
  return router;
263
285
  };
286
+
287
+ module.exports.settingsDeskRows = settingsDeskRows;
@@ -242,7 +242,12 @@ function accessRequestDocketMatter(r) {
242
242
  // The governor's docket: a pending request to join stands at People, shown only
243
243
  // to a viewer holding `access_request.review` (this queue's own atom, because it
244
244
  // exposes would-be builders' usernames) and needing that same viewer's act.
245
+ // The People desk's invite row (task 1004559), under the invite route's own atom:
246
+ // one GitHub name typed in place, sent to POST /access-requests/invite.
247
+ const INVITE_DESK_ROW = Object.freeze({ id: 'invite', name: 'Invite someone', fact: 'by their GitHub name', tool: true, ref: { type: 'invite', id: 'new' }, acts: [{ key: 'invite', field: 'github_login' }] });
248
+
245
249
  function contributeAccessRequestsToDocket() {
250
+ api.contribute('governor.docket', { desk: 'people.invite', gate: { permission: 'access_request.review' }, read: async () => [INVITE_DESK_ROW] });
246
251
  api.contribute('governor.docket', {
247
252
  source: 'access_requests',
248
253
  building: 'people',
@@ -651,3 +656,5 @@ module.exports = function buildAccessRequestsRouter() {
651
656
  };
652
657
 
653
658
  module.exports.accessRequestDocketMatter = accessRequestDocketMatter;
659
+
660
+ module.exports.INVITE_DESK_ROW = INVITE_DESK_ROW;
@@ -545,5 +545,5 @@ function makeUpdateChannelHandler({ provisioning, pool, parseId, validateOrRespo
545
545
  };
546
546
  }
547
547
 
548
- module.exports = { UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, selfUpdateChannel, makeUpdateChannelHandler, CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
548
+ module.exports = { loadPreview, UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, selfUpdateChannel, makeUpdateChannelHandler, CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
549
549
 
@@ -25,12 +25,45 @@
25
25
  const express = require('express');
26
26
  const api = require('../../../src/module-api');
27
27
  const provisioning = require('../provisioning');
28
- const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler, makeUpdateChannelHandler } = require('../core-upgrade');
28
+ const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler, makeUpdateChannelHandler, loadPreview } = require('../core-upgrade');
29
29
  const { requireOwnProjectOrPin } = require('../core-upgrade-authz');
30
30
 
31
31
  const log = api.logger('provisioning');
32
32
 
33
+ // The Release desk's core row (task 1004559): the platform's own move, in two
34
+ // steps on one row. Until a dry run has PASSED for the very version on offer the
35
+ // row offers only 'check' (POST …/core-upgrade with dry_run); 'take' (the real
36
+ // move) is handed only after that pass, so no desk can move the pin without the
37
+ // check. The chip carries the version, which the route requires as its own
38
+ // confirmation. A run in progress offers nothing (the row then steps aside for
39
+ // the plain core-update matter). Pure; exported for tests.
40
+ function coreDeskRows(preview) {
41
+ const p = preview || {};
42
+ const to = p.recommended_version;
43
+ if (!p.behind || !to || p.instance_id == null) return [];
44
+ if (p.open_intent && (p.open_intent.state === 'pending' || p.open_intent.state === 'running')) return [];
45
+ const pf = p.preflight && p.preflight.target === to ? p.preflight : null;
46
+ const version = [{ key: String(to), label: String(to) }];
47
+ const base = { id: p.instance_id, name: `Core ${to}`, over: 'core_update', opened_at: p.observed_at || null, ref: { type: 'core_pin', id: p.instance_id } };
48
+ if (pf && pf.ok) return [{ ...base, fact: 'checked · nothing blocks', facts: [`running ${p.served_version || 'unknown'}`], acts: [{ key: 'take', options: version }, { key: 'check', options: version }] }];
49
+ if (pf) return [{ ...base, fact: 'the check refused it', facts: [String(pf.error || 'no reason given'), `running ${p.served_version || 'unknown'}`], acts: [{ key: 'check', options: version }] }];
50
+ return [{ ...base, fact: 'not checked yet', facts: [`running ${p.served_version || 'unknown'}`], acts: [{ key: 'check', options: version }] }];
51
+ }
52
+ function contributeCoreDesk() {
53
+ api.contribute('governor.docket', {
54
+ desk: 'release.core',
55
+ // the control-plane read's own atom (GET /provisioning/control-plane)
56
+ gate: { permission: 'core.pin.move' },
57
+ read: async () => {
58
+ const { rows } = await api.pool.query("SELECT * FROM provisioning_instances WHERE hosting_shape = 'control-plane' LIMIT 1");
59
+ if (!rows.length) return [];
60
+ return coreDeskRows(await loadPreview(api.pool, provisioning, rows[0]));
61
+ },
62
+ });
63
+ }
64
+
33
65
  module.exports = function buildCoreUpgradeRouter() {
66
+ contributeCoreDesk();
34
67
  const router = express.Router();
35
68
 
36
69
  // POST /provisioning/instances/:id/core-upgrade — move the core pin.
@@ -119,3 +152,5 @@ module.exports = function buildCoreUpgradeRouter() {
119
152
 
120
153
  return router;
121
154
  };
155
+
156
+ module.exports.coreDeskRows = coreDeskRows;
@@ -56,6 +56,23 @@ function redteamDocketMatter(r) {
56
56
  tally: null,
57
57
  href: '/watch',
58
58
  ref: { type: 'vulnerability_report', id: r.id },
59
+ // The floor desk's acts (task 1004559): the docket only lists this for a
60
+ // holder of the adjudicate atom, which both routes check.
61
+ acts: [{ key: 'confirm' }, { key: 'dispute' }],
62
+ };
63
+ }
64
+
65
+ // A confirmed report still waiting for its fix, as a Disputes desk row (task
66
+ // 1004559): one act, POST /security/reports/:id/fix. Pure; exported for tests.
67
+ function confirmedDeskRow(r) {
68
+ return {
69
+ id: r.id,
70
+ name: r.title,
71
+ fact: r.severity ? `${r.severity} · confirmed` : 'confirmed',
72
+ facts: ['confirmed, not fixed yet'],
73
+ opened_at: r.confirmed_at || r.reported_at || null,
74
+ ref: { type: 'vulnerability_report', id: r.id },
75
+ acts: [{ key: 'fix' }],
59
76
  };
60
77
  }
61
78
 
@@ -69,6 +86,11 @@ function contributeReportsToDocket() {
69
86
  return lists.flat().map(redteamDocketMatter);
70
87
  },
71
88
  });
89
+ api.contribute('governor.docket', {
90
+ desk: 'disputes.confirmed',
91
+ gate: { permission: 'security.report.adjudicate' },
92
+ read: async () => (await security.listVulnerabilityReports({ scope: 'all', status: 'confirmed', limit: 40 })).map(confirmedDeskRow),
93
+ });
72
94
  // The Disputes record (task 1004527): every report that was ruled on, dated by
73
95
  // its ruling (confirmed or fixed). Same atom as the list route it reads.
74
96
  api.contribute('governor.docket', {
@@ -318,3 +340,4 @@ module.exports = function buildSecurityReportsRouter() {
318
340
 
319
341
  module.exports.redteamDocketMatter = redteamDocketMatter;
320
342
  module.exports.rulingHistory = rulingHistory;
343
+ module.exports.confirmedDeskRow = confirmedDeskRow;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.33",
3
+ "version": "1.21.35",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.33",
9
+ "version": "1.21.35",
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.21.33",
3
+ "version": "1.21.35",
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",
@@ -8958,5 +8958,17 @@
8958
8958
  "id": "1004494",
8959
8959
  "text": "The look for each craft mode is decided. Artistry, Ideating and Governing each get their own colour (moonlight, star gold, city blue) on the mode switch, the tab you are on and the top bar, the mode's name beside the page titl"
8960
8960
  }
8961
+ ],
8962
+ "1.21.34": [
8963
+ {
8964
+ "id": "1004558",
8965
+ "text": "Inside every building in the governor city, the floor you pick is now a desk you can act from, built from the new floor desks design. It shows the floor's name and how many things wait on you, up to three short numbers, and on"
8966
+ }
8967
+ ],
8968
+ "1.21.35": [
8969
+ {
8970
+ "id": "1004559",
8971
+ "text": "Governing now happens inside the buildings. On a floor's desk you can set a builder's tier, deactivate them, invite someone by GitHub name, give or take back a custom rank; confirm, dispute or mark fixed a security report and"
8972
+ }
8961
8973
  ]
8962
8974
  }
@@ -24,6 +24,7 @@ const auth = require('../auth');
24
24
  const { branding } = require('../../branding');
25
25
  const { resolveEnv } = require('../../instance-config');
26
26
  const { asyncHandler } = require('./_helpers');
27
+ const seams = require('../../module-seams');
27
28
  // task 1003208: structured logging — was console.*. A core file logs through
28
29
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
29
30
  const log = require('../logger').logger.child({ src: 'backup' });
@@ -76,6 +77,20 @@ function isStaleDump(stamp, nowMs = Date.now(), staleAfterHours = STALE_AFTER_HO
76
77
  return age > staleAfterHours;
77
78
  }
78
79
 
80
+ // The newest dump's age for the Treasury desk (task 1004559), read WITHOUT
81
+ // blocking: the docket is read on every street visit, so this is one async
82
+ // directory listing and the age from the filename (no stat), never latestDump()'s
83
+ // synchronous reads. Same filename rule and staleness rule as latestDump().
84
+ async function newestDumpAge(nowMs = Date.now()) {
85
+ let files;
86
+ try { files = await fs.promises.readdir(BACKUP_DIR); } catch { return null; }
87
+ const dumps = files.filter((f) => f.startsWith(`${DB_NAME}-`) && f.endsWith('.sql.gz')).sort();
88
+ if (!dumps.length) return null;
89
+ const ts = dumps[dumps.length - 1].replace(`${DB_NAME}-`, '').replace('.sql.gz', '');
90
+ const age = dumpAgeHours(ts, nowMs);
91
+ return { age_hours: age === null ? null : Math.round(age * 10) / 10, stale: isStaleDump(ts, nowMs) };
92
+ }
93
+
79
94
  function latestDump(nowMs = Date.now()) {
80
95
  let files;
81
96
  try {
@@ -107,7 +122,16 @@ function latestDump(nowMs = Date.now()) {
107
122
  };
108
123
  }
109
124
 
125
+ // The Treasury desk's backup row (task 1004559), under the backup routes' own
126
+ // atom: one tap runs POST /backup/trigger. Pure; exported for tests.
127
+ function backupDeskRows(dump, running) {
128
+ const age = dump && Number.isFinite(Number(dump.age_hours)) ? Number(dump.age_hours) : null;
129
+ const fact = running ? 'backing up now' : age == null ? 'no backup yet' : age < 1 ? 'last one under an hour' : age < 48 ? `last ${Math.round(age)} hours ago` : `last ${Math.round(age / 24)} days ago`;
130
+ return [{ id: 'now', name: 'Back up now', fact, facts: dump && dump.stale ? ['the last one is stale'] : [], tool: true, ref: { type: 'backup', id: 'now' }, acts: running ? [] : [{ key: 'backup' }] }];
131
+ }
132
+
110
133
  module.exports = function buildBackupRouter() {
134
+ seams.contribute('governor.docket', { desk: 'treasury.backup', gate: { permission: 'backup.manage' }, read: async () => backupDeskRows(await newestDumpAge(), triggerRunning) });
111
135
  const router = express.Router();
112
136
 
113
137
  // rank: metic+archon — any working builder may check or trigger a backup. Gate:
@@ -168,3 +192,5 @@ module.exports.parseDumpTimestamp = parseDumpTimestamp;
168
192
  module.exports.dumpAgeHours = dumpAgeHours;
169
193
  module.exports.isStaleDump = isStaleDump;
170
194
  module.exports.STALE_AFTER_HOURS = STALE_AFTER_HOURS;
195
+
196
+ module.exports.backupDeskRows = backupDeskRows;
@@ -168,7 +168,45 @@ function fireDiscordBroadcast(text) {
168
168
  }
169
169
  }
170
170
 
171
+ // The People desk's builder rows (task 1004559): one row per active builder the
172
+ // viewer may act on, with the acts the viewer's own atoms allow: set tier
173
+ // (builder.rank.set, PATCH /builders/:id/rank) and deactivate (builder.status.set,
174
+ // PATCH /builders/:id/status). Never the viewer's own row: both routes refuse it.
175
+ // Pure; exported for tests.
176
+ const STOP_REASONS = Object.freeze([
177
+ { key: 'left', label: 'left the project' },
178
+ { key: 'quiet', label: 'gone quiet' },
179
+ { key: 'asked', label: 'asked to stop' },
180
+ ]);
181
+ function builderDeskRows(builders, { viewerId, mayRank, mayStop, ranks }) {
182
+ if (!mayRank && !mayStop) return [];
183
+ return (builders || []).filter((b) => b && String(b.id) !== String(viewerId)).map((b) => {
184
+ const acts = [];
185
+ // one rung up at most (the route refuses a multi-rung promotion), any rung down
186
+ const at = (ranks || []).indexOf(b.rank);
187
+ // a rank off the live ladder offers no tier chip: there is no rung to move by one
188
+ if (mayRank && at >= 0) acts.push({ key: 'tier', options: (ranks || []).filter((r, i) => r !== b.rank && i <= at + 1).map((r) => ({ key: r, label: r })) });
189
+ if (mayStop) acts.push({ key: 'deactivate', options: STOP_REASONS });
190
+ return { id: b.id, name: b.display_name || b.github_login || `builder ${b.id}`, fact: b.rank || 'builder', facts: b.github_login ? [`@${b.github_login}`] : [], ref: { type: 'builder', id: b.id }, acts };
191
+ });
192
+ }
193
+ // Contributed from the router factory: any builder passes the gate, and the read
194
+ // scopes itself to the viewer's atoms (an empty desk for a viewer holding neither).
195
+ function contributeBuilderDesk() {
196
+ seams.contribute('governor.docket', {
197
+ desk: 'people.builders',
198
+ gate: { anyBuilder: true },
199
+ read: async (ctx) => {
200
+ const mayRank = ctx.has('builder.rank.set');
201
+ const mayStop = ctx.has('builder.status.set');
202
+ if (!mayRank && !mayStop) return [];
203
+ return builderDeskRows(await db.listActiveBuilders(), { viewerId: ctx.builderId, mayRank, mayStop, ranks: db.LIVE_RANK_LADDER });
204
+ },
205
+ });
206
+ }
207
+
171
208
  module.exports = function buildBuildersRouter() {
209
+ contributeBuilderDesk();
172
210
  const router = express.Router();
173
211
 
174
212
  // rank: archon — sole chokepoint that mutates builders.rank; ADR 0016 §enforcement.
@@ -707,3 +745,5 @@ module.exports = function buildBuildersRouter() {
707
745
 
708
746
  return router;
709
747
  };
748
+
749
+ module.exports.builderDeskRows = builderDeskRows;
@@ -180,8 +180,24 @@ function contributeModuleRechecksToDocket() {
180
180
  });
181
181
  }
182
182
 
183
+ // The Rules desk's module rows (task 1004559), under the toggle's own atom
184
+ // (POST /modules/:key/enable, module.enable): each module on disk, turned on in
185
+ // one tap, or off with a one-tap 'yes' so a stray tap cannot pull one. Pure;
186
+ // exported for tests.
187
+ function moduleDeskRows(mods) {
188
+ return (mods || []).filter((m) => m && m.key).map((m) => ({
189
+ id: m.key, name: m.title || m.key, fact: m.enabled ? 'on' : 'off', facts: m.description ? [m.description] : [], tool: true,
190
+ ref: { type: 'module', id: m.key },
191
+ acts: [m.enabled ? { key: 'module_off', options: [{ key: 'yes', label: 'yes, turn off' }] } : { key: 'module_on' }],
192
+ }));
193
+ }
194
+ function contributeModuleDesk() {
195
+ seams.contribute('governor.docket', { desk: 'rules.modules', gate: { permission: 'module.enable' }, read: async () => moduleDeskRows(listModules()) });
196
+ }
197
+
183
198
  module.exports = function buildModulesRouter() {
184
199
  contributeModuleRechecksToDocket();
200
+ contributeModuleDesk();
185
201
  const router = express.Router();
186
202
  // Checked how-tos by "key@version:tarball_sha256". Per router, so a test's fresh
187
203
  // router starts empty.
@@ -702,3 +718,5 @@ module.exports = function buildModulesRouter() {
702
718
  // own disk scan, no DB), so it's safe to require directly outside an Express
703
719
  // request cycle, the same way tests/atlas.mjs requires gen-atlas.js's builders.
704
720
  module.exports.listModules = listModules;
721
+
722
+ module.exports.moduleDeskRows = moduleDeskRows;
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.33'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.35'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -285,7 +285,7 @@ test('the response shape: counts, line, street-ordered buildings, sorted matters
285
285
  c.readings.set('flow', { reading: 'flow', gate: { anyBuilder: true }, read: async () => ({ value: 3, figure: '3 stuck', line: 'longest wait 4 days, waiting for review', hot: true }) });
286
286
  const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast', 'security.report.adjudicate'], contributions: c, now: NOW });
287
287
 
288
- assert.deepEqual(Object.keys(d).sort(), ['buildings', 'counts', 'floors', 'generated_at', 'history', 'history_sources', 'line', 'matters', 'readings', 'sources', 'viewer', 'vocabulary'].sort());
288
+ assert.deepEqual(Object.keys(d).sort(), ['buildings', 'counts', 'desks', 'floors', 'generated_at', 'history', 'history_sources', 'line', 'matters', 'readings', 'sources', 'viewer', 'vocabulary'].sort());
289
289
  assert.deepEqual(d.viewer, { id: '7' });
290
290
  // board 11 (needs) + board 12 (settled, closed this week) + blocker + report; board 13 is too old
291
291
  assert.deepEqual(d.counts, { needs: 2, moving: 1, settled: 1 });
@@ -734,3 +734,30 @@ test('GET /government/docket: 403 without board.vote.cast, the documented shape
734
734
  assert.equal(body.sources.find((s) => s.source === 'access_requests').status, 'not_yours');
735
735
  assert.ok(!JSON.stringify(body).includes('Admit Kai'), 'a hidden source leaks nothing');
736
736
  });
737
+
738
+ // ── the floor desks' acts and rows (task 1004559) ────────────────────────────
739
+
740
+ test('acts: a matter carries only well-formed acts, at most two, with at most five one-tap options', () => {
741
+ const m = docket.normalizeMatter({ id: 1, title: 't', acts: [{ key: 'confirm', options: [1, 2, 3, 4, 5, 6].map((n) => ({ key: n, label: `option ${n}` })) }, { key: 'Bad Key' }, { key: 'fix' }, { key: 'third' }] }, { source: 's', building: 'review' });
742
+ assert.deepEqual(m.acts.map((a) => a.key), ['confirm', 'fix']);
743
+ assert.equal(m.acts[0].options.length, 5);
744
+ assert.equal('acts' in docket.normalizeMatter({ id: 1, title: 't' }, { source: 's', building: 'review' }), false, 'no acts, no field');
745
+ assert.deepEqual(docket.normalizeActs([{ key: 'allow', pick: 'open_goals', except: [1, 2] }, { key: 'pause', pick: 'everything' }]), [{ key: 'allow', pick: 'open_goals', except: ['1', '2'] }, { key: 'pause' }]);
746
+ assert.equal(docket.normalizeDeskRow({ id: 1, name: 'n', acts: [] }, { desk: 'people.builders', building: 'people' }), null, 'a desk row with nothing to do is dropped');
747
+ });
748
+
749
+ test('desks: gated by the atom their write route checks; a gated-out desk is not_yours with no rows; a broken one is unavailable', async () => {
750
+ const c = none();
751
+ c.desks = new Map([
752
+ ['people.builders', { desk: 'people.builders', gate: { permission: 'builder.rank.set' }, read: async () => [{ id: 7, name: 'Ana', fact: 'thetes', acts: [{ key: 'tier', options: [{ key: 'metic', label: 'metic' }] }] }, { id: 8, name: 'no acts', acts: [] }] }],
753
+ ['automation.goals', { desk: 'automation.goals', gate: { anyBuilder: true }, read: async () => { throw new Error('boom'); } }],
754
+ ]);
755
+ const without = await docket.buildDocket({ viewer: VIEWER, permissions: [], contributions: c, now: NOW });
756
+ const by = (d, k) => d.desks.find((x) => x.key === k);
757
+ assert.deepEqual(by(without, 'people.builders'), { key: 'people.builders', building: 'people', status: 'not_yours', rows: [] });
758
+ assert.equal(by(without, 'automation.goals').status, 'unavailable');
759
+ assert.equal(by(without, 'automation.runners').status, 'off');
760
+ const withAtom = await docket.buildDocket({ viewer: VIEWER, permissions: ['builder.rank.set'], contributions: c, now: NOW });
761
+ assert.deepEqual(by(withAtom, 'people.builders').rows.map((r) => [r.id, r.name, r.acts[0].key]), [['people.builders:7', 'Ana', 'tier']]);
762
+ assert.equal(withAtom.matters.length, 0, 'desk rows never become matters (no pins, no counts)');
763
+ });
@@ -177,9 +177,18 @@ test('every act maps to the route that does it, with the body that route accepts
177
177
  assert.deepEqual(C.request(gate, 'release'), { method: 'POST', url: '/api/bongos/gate-approvals/812/approve', body: {} });
178
178
  });
179
179
 
180
- test('no act on any card reaches the core pin or a provisioning route', () => {
180
+ // Task 1004559 moved the core update onto the Release desk, check first: the ONLY
181
+ // way a card reaches the pin is the core_pin row, whose 'take' the server hands
182
+ // only after a passed dry run (tests/hall_street_desk.mjs pins that), and which
183
+ // needs the version chip as its confirmation. Nothing else may name the route.
184
+ test('only the core_pin row reaches the core pin, and never without its version', () => {
181
185
  const src = read('street-card.js').replace(/^\s*\/\/.*$/gm, '');
182
- assert.doesNotMatch(src, /core-upgrade|provisioning|\/core-update/);
186
+ const block = src.split('ACT_ROUTES.core_pin = {')[1].split('};')[0];
187
+ assert.equal(src.replace(block, '').match(/core-upgrade|provisioning|\/core-update/g), null, 'no other act names a provisioning route');
188
+ const cv = C.actsCard({ key: 'release.core:41', kind: 'core_pin', id: '41' });
189
+ assert.equal(C.request(cv, 'take', {}), null);
190
+ assert.equal(C.request(cv, 'check', {}), null);
191
+ assert.deepEqual(JSON.parse(JSON.stringify(C.request(cv, 'check', { option: '1.21.20' }))).body, { to: '1.21.20', dry_run: true });
183
192
  });
184
193
 
185
194
  test('an objection names its reason and its part before anything is sent; the parts are the server\'s own keys', () => {
@@ -258,7 +267,7 @@ async function bootPage({ feeds, posts = {} }) {
258
267
  };
259
268
  const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite });
260
269
  ctx.globalThis = ctx;
261
- for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
270
+ for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street.js']) vm.runInContext(read(f), ctx);
262
271
  await window.GovStreetPage.ready;
263
272
  return { window, byId, calls, focused };
264
273
  }
@@ -376,7 +376,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
376
376
  assert.match(html, /<a class="street-mark" id="streetHome" href="\/builders\/city"[^>]*>\{\{productName\}\}</);
377
377
  for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
378
378
  const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
379
- assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-amend', 'street'], 'the hall shell (task 1004544), the shared kit, then the card (task 1004528), the interiors (task 1004526) and the drafted amendment (task 1004530)');
379
+ assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the shared kit, then the card (task 1004528), the interiors (task 1004526), the floor desk (task 1004558) and the drafted amendment (task 1004530)');
380
380
  assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
381
381
  });
382
382
 
@@ -253,7 +253,7 @@ async function bootPage({ hash = '#building=review', cons = constitution(), answ
253
253
  };
254
254
  const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map, setTimeout, clearTimeout });
255
255
  ctx.globalThis = ctx;
256
- for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-amend.js', 'street.js']) vm.runInContext(read(f), ctx);
256
+ for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street-amend.js', 'street.js']) vm.runInContext(read(f), ctx);
257
257
  await window.GovStreetPage.ready;
258
258
  await tick();
259
259
  return { window, byId, focused, gets, posts, history };