@bongos/core 1.21.91 → 1.21.93

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 (55) hide show
  1. package/.bongos-core.json +72 -62
  2. package/docs/adr/0363-who-decides-what-is-a-table-changed-only-by-amendment.md +1 -0
  3. package/docs/copy-inventory.md +357 -367
  4. package/docs/copy-registry.json +559 -641
  5. package/docs/module-api-changelog.md +9 -0
  6. package/docs/page-inventory.json +15 -18
  7. package/docs/page-readings.json +1235 -918
  8. package/modules/autonomy/routes/autonomy.js +14 -5
  9. package/modules/government/decision-board.js +13 -3
  10. package/modules/government/decision-rules.js +61 -13
  11. package/modules/lifecycle/routes/done-when.js +2 -2
  12. package/modules/lifecycle/routes/gate-approvals.js +1 -1
  13. package/modules/lifecycle/routes/goals.js +4 -4
  14. package/modules/lifecycle/routes/project-settings.js +2 -2
  15. package/modules/lifecycle/routes/tasks.js +1 -1
  16. package/modules/lifecycle/routes/versions.js +1 -1
  17. package/modules/npm-release/routes/release.js +1 -0
  18. package/modules/onboarding/routes/access-requests.js +3 -3
  19. package/modules/provisioning/routes/core-upgrade.js +11 -0
  20. package/modules/public-landing/public/projects-hall-live.states.json +6 -35
  21. package/modules/public-landing/public/projects.html +1019 -983
  22. package/modules/public-landing/public/projects.probes.json +15 -18
  23. package/modules/public-landing/public/projects.states.json +22 -25
  24. package/modules/render-deploy/routes/act.js +1 -1
  25. package/modules/security/routes/reports.js +3 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +12 -0
  29. package/scripts/gds/fitness.js +7 -0
  30. package/src/bongos/act-alone.js +84 -0
  31. package/src/bongos/routes/builders.js +6 -1
  32. package/src/module-api.js +7 -1
  33. package/tests/autonomy_fence_priority.mjs +3 -1
  34. package/tests/government_act_alone_buildings.mjs +430 -0
  35. package/tests/module_api.mjs +1 -0
  36. package/tests/npm_release_release.mjs +2 -1
  37. package/tests/onboard.mjs +8 -8
  38. package/tests/project_invite_ui.mjs +6 -5
  39. package/tests/projects_hub.mjs +77 -98
  40. package/tests/projects_hub_app_status.mjs +3 -3
  41. package/tests/projects_hub_app_step.mjs +8 -8
  42. package/tests/projects_hub_cloud_hop.mjs +29 -53
  43. package/tests/projects_hub_look.mjs +29 -39
  44. package/tests/projects_hub_module_picker.mjs +32 -39
  45. package/tests/projects_hub_pre_uat.mjs +11 -14
  46. package/tests/ship_mode.mjs +2 -1
  47. package/tests/wizard_demo.mjs +30 -25
  48. package/tests/wizard_draft_resume.mjs +24 -32
  49. package/tests/wizard_front_door.mjs +91 -243
  50. package/tests/wizard_intent_resume.mjs +28 -48
  51. package/tests/wizard_one_page.mjs +201 -0
  52. package/tests/wizard_physics.mjs +46 -41
  53. package/tests/wizard_physics_more.mjs +71 -106
  54. package/tests/wizard_ship_mode.mjs +1 -1
  55. package/tests/wizard_six_screens.mjs +0 -129
@@ -155,6 +155,15 @@ module.exports = function buildAutonomyRouter() {
155
155
  }
156
156
  });
157
157
 
158
+ // WHO DECIDES AT AUTOMATION (task 1004657; ADR 0363 D5). While the project's
159
+ // decision-rules row hands Automation to a sitting, these writes refuse one
160
+ // builder acting alone (409 decided_by_sitting) and point at the matter route;
161
+ // a passed matter acts through matter-acts.js, never through these routes.
162
+ // The BRAKE is exempt: switching unattended building off, or pausing one
163
+ // runner, never waits for a meeting — stopping can only narrow what runs.
164
+ const isTheBrake = (req) => !!req.body && req.body.enabled === false;
165
+ const isAPause = (req) => !!req.body && req.body.paused === true;
166
+
158
167
  // ── the Autobongos fence (task 1003908, ADR 0307 §6) ───────────────────────
159
168
  //
160
169
  // These five routes ARE the fence. Everything the unattended runner is allowed
@@ -195,7 +204,7 @@ module.exports = function buildAutonomyRouter() {
195
204
 
196
205
  // The kill switch. Archon, system:true — see the catalog entry for why it is the
197
206
  // one permission that cannot be delegated to a custom rank.
198
- router.post('/autonomy/fence', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
207
+ router.post('/autonomy/fence', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), auth.requireActAlone('automation', { unless: isTheBrake }), async (req, res) => {
199
208
  if (validateOrRespond(req, res, {
200
209
  enabled: { required: true, type: 'boolean' },
201
210
  reason: { type: 'string', maxLength: 500 },
@@ -217,7 +226,7 @@ module.exports = function buildAutonomyRouter() {
217
226
  });
218
227
 
219
228
  // Add a goal to the allowlist.
220
- router.post('/autonomy/fence/goals', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
229
+ router.post('/autonomy/fence/goals', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), auth.requireActAlone('automation'), async (req, res) => {
221
230
  if (validateOrRespond(req, res, {
222
231
  goal_id: { required: true }, // number or stringified number; coerced below
223
232
  note: { type: 'string', maxLength: 500 },
@@ -237,7 +246,7 @@ module.exports = function buildAutonomyRouter() {
237
246
 
238
247
  // Revoke one. `removed` distinguishes "just revoked" from "already gone" — an
239
248
  // owner clicking Remove deserves to know which happened.
240
- router.delete('/autonomy/fence/goals/:goalId', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
249
+ router.delete('/autonomy/fence/goals/:goalId', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), auth.requireActAlone('automation'), async (req, res) => {
241
250
  const goalId = Number(req.params.goalId);
242
251
  if (!Number.isSafeInteger(goalId) || goalId <= 0) return res.fail('bad_goal_id', 400);
243
252
  try {
@@ -255,7 +264,7 @@ module.exports = function buildAutonomyRouter() {
255
264
  // first is part of choosing what it may do. The goal must already be on the
256
265
  // allowlist (409 otherwise), so a priority can order the list and never widen
257
266
  // it. `goal_id: null` clears it.
258
- router.post('/autonomy/fence/priority', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
267
+ router.post('/autonomy/fence/priority', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), auth.requireActAlone('automation'), async (req, res) => {
259
268
  // Declared with an open rule: the value is a number OR null, which the
260
269
  // validator has no type for, so the shape is checked just below.
261
270
  if (validateOrRespond(req, res, { goal_id: {} })) return;
@@ -302,7 +311,7 @@ module.exports = function buildAutonomyRouter() {
302
311
  // switch. Same atom as the kill switch: stopping a machine is the same act
303
312
  // whether it is everyone's or one person's, and the paused builder must not be
304
313
  // able to lift it (autonomy.run does not reach this route).
305
- router.post('/autonomy/builders/:builderId/pause', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
314
+ router.post('/autonomy/builders/:builderId/pause', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), auth.requireActAlone('automation', { unless: isAPause }), async (req, res) => {
306
315
  if (validateOrRespond(req, res, {
307
316
  paused: { required: true, type: 'boolean' },
308
317
  reason: { type: 'string', maxLength: 500 },
@@ -286,13 +286,23 @@ async function openMatter({ area, title, detailMd = null, action = null, propose
286
286
  }
287
287
 
288
288
  // May this builder act ALONE at this building — the check a building's own
289
- // direct route runs before acting (People's rank assignments today). Resolved
289
+ // direct route runs before acting (People's rank assignments here; every other
290
+ // building's routes through the doorway's requireActAlone, task 1004657 — the
291
+ // list is decision-rules AREAS[].acts). Resolved
290
292
  // per request against the live row and the builder's live ranks, uncached.
291
293
  // Returns { ok: true } or { ok: false, reason: 'decided_by_sitting', … }.
294
+ //
295
+ // HOT PATH since task 1004657: ~30 write routes across the modules mount this
296
+ // through requireActAlone, so it does ONE query and no file I/O. The board block
297
+ // (loadGovernmentConfig — two synchronous file reads, boot-only by config.js's own
298
+ // rule) only ever shapes the CHARTER row's rule and words, and no direct route
299
+ // asks about the charter; every other building's row reads the same without it.
292
300
  async function checkActAlone(area, builderId) {
293
- const { board } = loadGovernmentConfig();
301
+ if (!decisionRules.AREA_KEYS.includes(area)) return { ok: false, reason: 'unknown_area' };
294
302
  const rows = await db.listDecisionRules();
295
- const resolved = decisionRules.resolveAll(rows, { board }).find((r) => r.area === area);
303
+ const board = area === 'charter' ? loadGovernmentConfig().board : {};
304
+ const stored = (rows || []).find((r) => r.area === area) || decisionRules.defaultRow(area);
305
+ const resolved = decisionRules.resolveRow(stored, { board });
296
306
  if (!resolved) return { ok: false, reason: 'unknown_area' };
297
307
  if (resolved.as_today) return { ok: true, decided: resolved };
298
308
  const seated = resolved.sitting
@@ -44,30 +44,77 @@ const { RANK_ORDER } = require('./catalog');
44
44
  // The nine buildings, in street order (held equal to docket-vocab BUILDINGS by
45
45
  // test). `gate` is the fixed permission that decides the building's matters
46
46
  // today — the atom its own pages require, named so a reader can see what a
47
- // sitting replaces. `acts` lists the direct acts in THIS module that already
48
- // defer to the row (task 1004529 wired People's rank assignments); every other
49
- // building's own pages keep their gate until their owning module adopts
50
- // `mayActAlone` (the ADR's follow-up list).
47
+ // sitting replaces. `acts` is THE RECORD of every direct route that defers to
48
+ // the building's row: while the row names a sitting, each refuses one builder
49
+ // acting alone (409 decided_by_sitting) and points at the matter route. People's
50
+ // rank assignments ask in this module (task 1004529); every other route mounts
51
+ // the doorway's requireActAlone(area) after its permission gate (task 1004657).
52
+ // tests/government_act_alone_buildings.mjs reads this list against the source
53
+ // both ways, so a route cannot defer without being listed, or be listed without
54
+ // deferring. Which routes are a building's: those gated by its `gate` atom,
55
+ // plus the routes its passed matters act through (decision-board MATTER_ACTIONS)
56
+ // and its docket desks reach. A building with no direct route says so in
57
+ // `no_direct_route` — the charter, whose only act is an amendment.
58
+ // NOT a lone act, so never refused: a BRAKE (switching unattended building off,
59
+ // pausing one runner — stopping never waits for a meeting), a dry run, and a
60
+ // tenant project's own owner moving their own pin (that project's decision).
51
61
  const AREAS = Object.freeze([
52
62
  Object.freeze({ key: 'charter', name: 'Charter', gate: null,
53
- today: 'the board, under the constitution in force', acts: Object.freeze([]) }),
63
+ today: 'the board, under the constitution in force', acts: Object.freeze([]),
64
+ no_direct_route: 'the charter is changed only by amending the constitution — POST /government/board/items or /government/board/charter' }),
54
65
  Object.freeze({ key: 'rules', name: 'Rules', gate: 'project.settings.manage',
55
- today: 'whoever may change the project settings', acts: Object.freeze([]) }),
66
+ today: 'whoever may change the project settings', acts: Object.freeze([
67
+ 'PATCH /project-settings/rot-days',
68
+ 'PATCH /project-settings/ship-mode',
69
+ 'PATCH /work-categories/:id',
70
+ ]) }),
56
71
  Object.freeze({ key: 'disputes', name: 'Disputes', gate: 'security.report.adjudicate',
57
- today: 'whoever may judge reports', acts: Object.freeze([]) }),
72
+ today: 'whoever may judge reports', acts: Object.freeze([
73
+ 'POST /security/reports/:id/confirm',
74
+ 'POST /security/reports/:id/dispute',
75
+ 'POST /security/reports/:id/fix',
76
+ ]) }),
58
77
  Object.freeze({ key: 'people', name: 'People', gate: 'government.manage',
59
78
  today: 'whoever may manage ranks',
60
- acts: Object.freeze(['POST /government/assignments', 'DELETE /government/assignments/:builderId/:rankKey']) }),
79
+ acts: Object.freeze([
80
+ 'POST /government/assignments',
81
+ 'DELETE /government/assignments/:builderId/:rankKey',
82
+ 'PATCH /access-requests/:id',
83
+ 'POST /access-requests/invite',
84
+ ]) }),
61
85
  Object.freeze({ key: 'planning', name: 'Planning', gate: 'goal.create',
62
- today: 'whoever may create goals', acts: Object.freeze([]) }),
86
+ today: 'whoever may create goals', acts: Object.freeze([
87
+ 'POST /goals',
88
+ 'POST /goals/reorder',
89
+ 'POST /work-categories',
90
+ 'PUT /versions/focus',
91
+ ]) }),
63
92
  Object.freeze({ key: 'automation', name: 'Automation', gate: 'autonomy.fence.manage',
64
- today: 'whoever may switch unattended building on and off', acts: Object.freeze([]) }),
93
+ today: 'whoever may switch unattended building on and off', acts: Object.freeze([
94
+ 'POST /autonomy/fence',
95
+ 'POST /autonomy/fence/goals',
96
+ 'DELETE /autonomy/fence/goals/:goalId',
97
+ 'POST /autonomy/fence/priority',
98
+ 'POST /autonomy/builders/:builderId/pause',
99
+ ]) }),
65
100
  Object.freeze({ key: 'review', name: 'Review', gate: 'criterion.satisfy',
66
- today: 'whoever may mark work as meeting its checks', acts: Object.freeze([]) }),
101
+ today: 'whoever may mark work as meeting its checks', acts: Object.freeze([
102
+ 'POST /done-when/:criterionId/satisfy',
103
+ 'POST /done-when/:criterionId/unsatisfy',
104
+ ]) }),
67
105
  Object.freeze({ key: 'release', name: 'Release', gate: 'core.pin.move',
68
- today: 'whoever may put a release live', acts: Object.freeze([]) }),
106
+ today: 'whoever may put a release live', acts: Object.freeze([
107
+ 'POST /gate-approvals/:pr/approve',
108
+ 'POST /override-requests/:id/decide',
109
+ 'POST /provisioning/instances/:id/core-upgrade',
110
+ 'POST /provisioning/instances/:id/update-channel',
111
+ 'POST /render-deploy/act',
112
+ 'POST /npm-release/release',
113
+ ]) }),
69
114
  Object.freeze({ key: 'treasury', name: 'Treasury', gate: 'builder.budget.set',
70
- today: 'whoever may set spending limits', acts: Object.freeze([]) }),
115
+ today: 'whoever may set spending limits', acts: Object.freeze([
116
+ 'PATCH /builders/:id/budget',
117
+ ]) }),
71
118
  ]);
72
119
  const AREA_KEYS = Object.freeze(AREAS.map((a) => a.key));
73
120
  const areaByKey = (key) => AREAS.find((a) => a.key === key) || null;
@@ -245,6 +292,7 @@ function resolveRow(stored, { board = {} } = {}) {
245
292
  gate: def.gate,
246
293
  today: def.today,
247
294
  acts: def.acts,
295
+ no_direct_route: def.no_direct_route || null,
248
296
  set_by_item_id: row.set_by_item_id == null ? null : String(row.set_by_item_id),
249
297
  updated_at: row.updated_at || null,
250
298
  });
@@ -248,7 +248,7 @@ module.exports = function buildDoneWhenRouter() {
248
248
  // everywhere — never as a UAT. The escape hatch stays (an all-abandoned
249
249
  // criterion that was met another way still needs one); it is just no longer
250
250
  // silent.
251
- router.post('/done-when/:criterionId/satisfy', auth.requireBuilder, auth.requirePermission('criterion.satisfy'), asyncHandler('POST /done-when/:criterionId/satisfy', async (req, res) => {
251
+ router.post('/done-when/:criterionId/satisfy', auth.requireBuilder, auth.requirePermission('criterion.satisfy'), auth.requireActAlone('review'), asyncHandler('POST /done-when/:criterionId/satisfy', async (req, res) => {
252
252
  if (validateOrRespond(req, res, {
253
253
  satisfied_by_task_id: { type: 'integer', min: 1 },
254
254
  override_reason: { type: 'string', maxLength: 2000 },
@@ -476,7 +476,7 @@ module.exports = function buildDoneWhenRouter() {
476
476
  // rank: archon — un-flip a criterion; same gate as satisfy.
477
477
  // An optional `reason` (the Review desk's one-tap chip, task 1004561) is
478
478
  // logged beside who undid it; the flip itself is unchanged.
479
- router.post('/done-when/:criterionId/unsatisfy', auth.requireBuilder, auth.requirePermission('criterion.unsatisfy'), asyncHandler('POST /done-when/:criterionId/unsatisfy', async (req, res) => {
479
+ router.post('/done-when/:criterionId/unsatisfy', auth.requireBuilder, auth.requirePermission('criterion.unsatisfy'), auth.requireActAlone('review'), asyncHandler('POST /done-when/:criterionId/unsatisfy', async (req, res) => {
480
480
  if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 200 } })) return;
481
481
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
482
482
  if (id === null) return;
@@ -255,7 +255,7 @@ module.exports = function buildGateApprovalsRouter() {
255
255
  // POST /gate-approvals/:pr/approve — record owner approval for a stuck gate PR and
256
256
  // re-run the gate-review check so it clears. rank: archon (clears the hard floor —
257
257
  // pinned in route-rank-check.EXPECTED_RANKS for downgrade protection).
258
- router.post('/gate-approvals/:pr/approve', auth.requireBuilder, auth.requirePermission('gate_approval.decide'), async (req, res) => {
258
+ router.post('/gate-approvals/:pr/approve', auth.requireBuilder, auth.requirePermission('gate_approval.decide'), auth.requireActAlone('release'), async (req, res) => {
259
259
  const number = parseId(req, res, { param: 'pr', code: 'bad_pr', positiveInt: true });
260
260
  if (number == null) return;
261
261
  try {
@@ -83,7 +83,7 @@ module.exports = function buildGoalsRouter() {
83
83
  // POST /goals — create a goal.
84
84
  // rank: metic+archon — a Metic or Archon may create a goal; creation of a goal
85
85
  // whose scope_modules includes a PROTECTED module is Archon-only (in-handler).
86
- router.post('/goals', auth.requireBuilder, auth.requirePermission('goal.create'), async (req, res) => {
86
+ router.post('/goals', auth.requireBuilder, auth.requirePermission('goal.create'), auth.requireActAlone('planning'), async (req, res) => {
87
87
  if (validateOrRespond(req, res, {
88
88
  version_id: { required: true, type: 'string', minLength: 1, maxLength: 64 },
89
89
  title: { required: true, type: 'string', minLength: 1, maxLength: LIMITS.TITLE },
@@ -239,7 +239,7 @@ module.exports = function buildGoalsRouter() {
239
239
  // Mounted BEFORE GET /goals/:id so 'reorder' is never captured as an id.
240
240
  // rank: archon — priority is an owner decision, never self-granted by a
241
241
  // goal's own member/manager (unlike the goal-authority routes above).
242
- router.post('/goals/reorder', auth.requireBuilder, auth.requirePermission('goal.reorder'), async (req, res) => {
242
+ router.post('/goals/reorder', auth.requireBuilder, auth.requirePermission('goal.reorder'), auth.requireActAlone('planning'), async (req, res) => {
243
243
  if (validateOrRespond(req, res, {
244
244
  version_id: { required: true, type: 'string', minLength: 1, maxLength: 64 },
245
245
  ordered_ids: { required: true, type: 'array', maxItems: 500, itemsType: 'integer' },
@@ -425,7 +425,7 @@ module.exports = function buildGoalsRouter() {
425
425
  // prompt writes into. Same permission as authoring a goal: extending the project's
426
426
  // vocabulary is a planning act. A duplicate slug returns the existing row (200),
427
427
  // so two people prompted at once converge instead of one being stranded.
428
- router.post('/work-categories', auth.requireBuilder, auth.requirePermission('goal.create'),
428
+ router.post('/work-categories', auth.requireBuilder, auth.requirePermission('goal.create'), auth.requireActAlone('planning'),
429
429
  goalCategory.categoryCreateHandler({ validateOrRespond, LIMITS, log }));
430
430
  // PATCH /work-categories/:id — body { label?, sort_order?, active? }: rename a
431
431
  // category, move it in the order, or stop offering it. The set has been list+add
@@ -440,7 +440,7 @@ module.exports = function buildGoalsRouter() {
440
440
  // /goal-create prompt refer to. Deactivating never deletes — goals already carrying
441
441
  // a retired category keep a resolvable label.
442
442
  router.patch('/work-categories/:id', auth.requireBuilder,
443
- auth.requirePermission('project.settings.manage'),
443
+ auth.requirePermission('project.settings.manage'), auth.requireActAlone('rules'),
444
444
  goalCategory.categoryUpdateHandler({ validateOrRespond, LIMITS, parseId, log }));
445
445
 
446
446
  // PATCH /goals/:id/category — body { category_id } (null clears it). Separate from
@@ -313,7 +313,7 @@ module.exports = function buildProjectSettingsRouter() {
313
313
  // first and fitness.js caught it; that is why every gated route in the tree spells
314
314
  // its atom inline. (Wrapping the middleware list across lines is fine — the sibling
315
315
  // PATCH /work-categories/:id does exactly that and still classifies metic+archon.)
316
- router.patch('/project-settings/rot-days', api.requireBuilder, api.requirePermission('project.settings.manage'), async (req, res) => {
316
+ router.patch('/project-settings/rot-days', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
317
317
  // STRICT schema first (ADR 0118): it declares the only field this handler reads,
318
318
  // so an unknown key is refused through the ADR 0116 envelope instead of being
319
319
  // silently dropped. It settles the TYPE; parseDays below settles the RANGE, using
@@ -364,7 +364,7 @@ module.exports = function buildProjectSettingsRouter() {
364
364
  // rank: project.settings.manage (metic+, government_010) — the same atom as the
365
365
  // other writes on this page. Validated with the resolver's own parser, so the
366
366
  // page, the CLI and the reader can never disagree about what a mode is.
367
- router.patch('/project-settings/ship-mode', api.requireBuilder, api.requirePermission('project.settings.manage'), async (req, res) => {
367
+ router.patch('/project-settings/ship-mode', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
368
368
  if (api.validateOrRespond(req, res, { mode: { required: true, type: 'string' } })) return;
369
369
  const parsed = api.projectSettings.parseShipMode(req.body.mode);
370
370
  if (!parsed.mode) {
@@ -1437,7 +1437,7 @@ module.exports = function buildTasksRouter() {
1437
1437
  // caller; this route only renders its refusal (403 override_owner_only).
1438
1438
  // Approving one confirms nothing and pays nothing — it stops the review holding
1439
1439
  // the DEPLOY and leaves it open on the board.
1440
- router.post('/override-requests/:id/decide', auth.requireBuilder, auth.requirePermission('override_request.decide'), async (req, res) => {
1440
+ router.post('/override-requests/:id/decide', auth.requireBuilder, auth.requirePermission('override_request.decide'), auth.requireActAlone('release'), async (req, res) => {
1441
1441
  const id = parseId(req, res, { code: 'bad_request_id' });
1442
1442
  if (id === null) return;
1443
1443
  if (validateOrRespond(req, res, {
@@ -56,7 +56,7 @@ module.exports = function buildVersionsRouter() {
56
56
 
57
57
  // rank: archon (atom version.focus.set, system) — body { version_id } sets the focus; { version_id: null } clears it.
58
58
  // An unknown version id is refused rather than stored.
59
- router.put('/versions/focus', auth.requireBuilder, auth.requirePermission('version.focus.set'), asyncHandler('PUT /versions/focus', async (req, res) => {
59
+ router.put('/versions/focus', auth.requireBuilder, auth.requirePermission('version.focus.set'), auth.requireActAlone('planning'), asyncHandler('PUT /versions/focus', async (req, res) => {
60
60
  if (validateOrRespond(req, res, { version_id: { type: 'string', maxLength: 64 } })) return;
61
61
  const raw = (req.body || {}).version_id;
62
62
  if (raw === undefined || (raw !== null && !raw.trim())) return res.fail('bad_version_id', { status: 400, message: 'Pick a version to star, or send null to clear the focus.' });
@@ -36,6 +36,7 @@ module.exports = function buildNpmReleaseReleaseRouter({
36
36
  '/npm-release/release',
37
37
  api.requireBuilder,
38
38
  api.requirePermission('core.pin.move'),
39
+ api.requireActAlone('release'),
39
40
  async (req, res) => {
40
41
  if (api.validateOrRespond(req, res, releaseSchema, { strict: true })) return undefined;
41
42
  if (!VERSION_RE.test(req.body.version)) {
@@ -58,7 +58,7 @@ const express = require('express');
58
58
  // core file a module may require, ADR 0083); approval-broadcast travels WITH this
59
59
  // admission route (its sole consumer) and is an intra-module require.
60
60
  const api = require('../../../src/module-api');
61
- const auth = { requireBuilder: api.requireBuilder, requirePermission: api.requirePermission };
61
+ const auth = { requireBuilder: api.requireBuilder, requirePermission: api.requirePermission, requireActAlone: api.requireActAlone };
62
62
  const { pool } = api;
63
63
  const { validateOrRespond, parseId, accountExistenceReadRateLimit } = api;
64
64
  // A recruit invite's door closes 30 days after it is sent, computed at read time
@@ -486,7 +486,7 @@ module.exports = function buildAccessRequestsRouter() {
486
486
  // the SET value. Postgres infers text from the assignment anyway, but no real
487
487
  // engine ever executes this statement — there is no integration test for this
488
488
  // route and CI has no Postgres — so the type is stated rather than relied on.
489
- router.patch('/access-requests/:id', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
489
+ router.patch('/access-requests/:id', auth.requireBuilder, auth.requirePermission('access_request.review'), auth.requireActAlone('people'), async (req, res) => {
490
490
  const id = parseId(req, res, { code: 'bad_request_id' });
491
491
  if (id === null) return;
492
492
  if (validateOrRespond(req, res, {
@@ -567,7 +567,7 @@ module.exports = function buildAccessRequestsRouter() {
567
567
  // pending request was resolved; 200 already_invited when an invite already
568
568
  // stands; 409 already_member when they have an account.
569
569
  // rank: archon — the same gate as resolving a request.
570
- router.post('/access-requests/invite', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
570
+ router.post('/access-requests/invite', auth.requireBuilder, auth.requirePermission('access_request.review'), auth.requireActAlone('people'), async (req, res) => {
571
571
  if (validateOrRespond(req, res, {
572
572
  github_login: { required: true, type: 'string', minLength: 1, maxLength: 60 },
573
573
  note: { type: 'string', maxLength: 500 },
@@ -62,6 +62,15 @@ function contributeCoreDesk() {
62
62
  });
63
63
  }
64
64
 
65
+ // WHO DECIDES AT RELEASE (task 1004657; ADR 0363 D5). The pin move and the
66
+ // update rule are Release's own acts when the PIN door admitted the caller: while
67
+ // the project's decision-rules row hands Release to a sitting, one builder acting
68
+ // alone is refused (409 decided_by_sitting). Not a lone Release act, so never
69
+ // asked: a tenant project's own owner moving THEIR project (ADR 0339 — that
70
+ // project's decision, not this one's), and a dry run, which only reads.
71
+ const notThisProjectsRelease = (req) => req.coreUpgradeAuthority === 'owner';
72
+ const notADecision = (req) => notThisProjectsRelease(req) || (!!req.body && req.body.dry_run === true);
73
+
65
74
  module.exports = function buildCoreUpgradeRouter() {
66
75
  contributeCoreDesk();
67
76
  const router = express.Router();
@@ -95,6 +104,7 @@ module.exports = function buildCoreUpgradeRouter() {
95
104
  '/provisioning/instances/:id/core-upgrade',
96
105
  api.requireBuilder,
97
106
  requireOwnProjectOrPin({ pinGate: api.requirePermission('core.pin.move'), provisioning, pool: api.pool, log }),
107
+ api.requireActAlone('release', { unless: notADecision }),
98
108
  makeCoreUpgradeHandler({
99
109
  provisioning,
100
110
  pool: api.pool,
@@ -130,6 +140,7 @@ module.exports = function buildCoreUpgradeRouter() {
130
140
  '/provisioning/instances/:id/update-channel',
131
141
  api.requireBuilder,
132
142
  requireOwnProjectOrPin({ pinGate: api.requirePermission('core.pin.move'), provisioning, pool: api.pool, log }),
143
+ api.requireActAlone('release', { unless: notThisProjectsRelease }),
133
144
  makeUpdateChannelHandler({
134
145
  provisioning,
135
146
  pool: api.pool,
@@ -32,22 +32,6 @@
32
32
  "wait",
33
33
  600
34
34
  ],
35
- [
36
- "click",
37
- "#termsAck"
38
- ],
39
- [
40
- "wait",
41
- 100
42
- ],
43
- [
44
- "click",
45
- "#wizStart"
46
- ],
47
- [
48
- "wait",
49
- 400
50
- ],
51
35
  [
52
36
  "fill",
53
37
  "#newName",
@@ -62,14 +46,6 @@
62
46
  "#f-desc",
63
47
  "A co-op farming game where the seasons never repeat."
64
48
  ],
65
- [
66
- "click",
67
- "#panel-what button[type=submit]"
68
- ],
69
- [
70
- "wait",
71
- 400
72
- ],
73
49
  [
74
50
  "select",
75
51
  "#physCountry",
@@ -91,13 +67,9 @@
91
67
  "click",
92
68
  "[data-phys=\"entity\"][data-val=\"intent-solo\"]"
93
69
  ],
94
- [
95
- "click",
96
- "#physNext"
97
- ],
98
70
  [
99
71
  "wait",
100
- 700
72
+ 300
101
73
  ],
102
74
  [
103
75
  "click",
@@ -135,11 +107,11 @@
135
107
  ],
136
108
  [
137
109
  "click",
138
- "#reviewGo"
110
+ "#termsAck"
139
111
  ],
140
112
  [
141
113
  "wait",
142
- 500
114
+ 100
143
115
  ],
144
116
  [
145
117
  "click",
@@ -159,12 +131,11 @@
159
131
  "#panel-done",
160
132
  "#wizInvite",
161
133
  "#wizInviteWho",
162
- "#humanSteps li.cur",
163
- "#stepPager",
164
- "#stepNext"
134
+ "#humanSteps"
165
135
  ],
166
136
  "hidden": [
167
- "#hop"
137
+ "#hop",
138
+ "#wizParts"
168
139
  ],
169
140
  "text": [
170
141
  [