@bongos/core 1.21.92 → 1.21.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +86 -56
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0363-who-decides-what-is-a-table-changed-only-by-amendment.md +1 -0
- package/docs/api/openapi.json +38 -7
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +354 -354
- package/docs/copy-registry.json +420 -420
- package/docs/design/project-startup-direction.md +7 -0
- package/docs/module-api-changelog.md +9 -0
- package/docs/page-readings.json +963 -992
- package/modules/autonomy/routes/autonomy.js +14 -5
- package/modules/government/decision-board.js +13 -3
- package/modules/government/decision-rules.js +61 -13
- package/modules/lifecycle/routes/done-when.js +2 -2
- package/modules/lifecycle/routes/gate-approvals.js +1 -1
- package/modules/lifecycle/routes/goals.js +4 -4
- package/modules/lifecycle/routes/project-settings.js +2 -2
- package/modules/lifecycle/routes/tasks.js +1 -1
- package/modules/lifecycle/routes/versions.js +1 -1
- package/modules/npm-release/routes/release.js +1 -0
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/provisioning/github-setup.js +129 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +3 -0
- package/modules/provisioning/routes/callback-page.js +66 -1
- package/modules/provisioning/routes/core-upgrade.js +11 -0
- package/modules/provisioning/routes/github-setup.js +113 -0
- package/modules/provisioning/routes/provisioning.js +11 -45
- package/modules/public-landing/public/projects.html +105 -57
- package/modules/render-deploy/routes/act.js +1 -1
- package/modules/security/routes/reports.js +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness.js +7 -0
- package/scripts/gds/oauth-secret.js +4 -2
- package/scripts/gds/provision-github-setup.js +148 -0
- package/scripts/gds/provision.js +19 -5
- package/src/bongos/act-alone.js +84 -0
- package/src/bongos/routes/builders.js +6 -1
- package/src/module-api.js +7 -1
- package/tests/autonomy_fence_priority.mjs +3 -1
- package/tests/github_one_trip.mjs +297 -0
- package/tests/government_act_alone_buildings.mjs +430 -0
- package/tests/module_api.mjs +1 -0
- package/tests/npm_release_release.mjs +2 -1
- package/tests/projects_hub_dns_ready.mjs +2 -1
- package/tests/projects_hub_pre_uat.mjs +17 -9
- package/tests/provision.mjs +51 -9
- package/tests/provisioning_github_app_routes.mjs +94 -8
- package/tests/ship_mode.mjs +2 -1
- package/tests/wizard_github_feedback.mjs +8 -5
|
@@ -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
|
|
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
|
-
|
|
301
|
+
if (!decisionRules.AREA_KEYS.includes(area)) return { ok: false, reason: 'unknown_area' };
|
|
294
302
|
const rows = await db.listDecisionRules();
|
|
295
|
-
const
|
|
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`
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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([
|
|
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 },
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/provisioning/github-setup.js — "Connect GitHub" in one trip, and the checklist
|
|
4
|
+
// that ticks itself (task 1004649; the design of record is docs/design/mocks/day-night-rooms/
|
|
5
|
+
// part 03: Approve the <name> app on GitHub · Install it on your repo · Builders can sign in ·
|
|
6
|
+
// Command-line sign-in is on).
|
|
7
|
+
//
|
|
8
|
+
// WHY. A founder used to make three separate trips to github.com after Create (approve the
|
|
9
|
+
// app, tick "Enable Device Flow", install the app on the repo), and nothing ever checked the
|
|
10
|
+
// last two. Now the manifest carries a setup_url, so GitHub sends the founder from "app
|
|
11
|
+
// created" straight on to "install it on your repo" and then back to the project page — and
|
|
12
|
+
// every tick on the checklist is a fact the SERVER has seen, never a click the page assumed:
|
|
13
|
+
//
|
|
14
|
+
// approve — GitHub created the app AND the runner placed its credentials (the runner
|
|
15
|
+
// records the app's slug only after the placement write, provision.js).
|
|
16
|
+
// install — the runner, holding the app's own key, asked GitHub whether the app is
|
|
17
|
+
// installed on the project's repo and GitHub said yes ('github-app-installed').
|
|
18
|
+
// The installation_id GitHub appends to the setup_url is NOT trusted: GitHub's
|
|
19
|
+
// docs warn it can be spoofed, so that redirect only records that the founder
|
|
20
|
+
// SAYS they installed it ('github-app-install-reported'), which makes the
|
|
21
|
+
// runner look again.
|
|
22
|
+
// sign_in — the runner saw the project report auth_configured (the manifest row 'done').
|
|
23
|
+
// device — the runner started a device-flow sign-in with the app's client id and GitHub
|
|
24
|
+
// did not refuse it with device_flow_disabled ('github-app-device-flow').
|
|
25
|
+
// GitHub offers no manifest field and no API to switch device flow on (the
|
|
26
|
+
// spike in this task, written up in docs/design/project-startup-direction.md),
|
|
27
|
+
// so this step is OPTIONAL: it reads 'later' until the founder ticks the box on
|
|
28
|
+
// GitHub, and it never holds the project back from being alive.
|
|
29
|
+
//
|
|
30
|
+
// No new columns (ADR 0083 §5 additive rule; this task carries no migration): the verified
|
|
31
|
+
// facts are rows of the existing append-only provisioning_events ledger, read since the
|
|
32
|
+
// latest attempt began. The db is injected.
|
|
33
|
+
|
|
34
|
+
// The provisioning_events names this leg writes. One table, so the runner (which writes the
|
|
35
|
+
// verified ones), the setup redirect (which writes the reported one) and the reader agree.
|
|
36
|
+
const SETUP_EVENTS = Object.freeze({
|
|
37
|
+
installReported: 'github-app-install-reported',
|
|
38
|
+
installed: 'github-app-installed',
|
|
39
|
+
deviceFlow: 'github-app-device-flow',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// How long after an attempt settles the runner keeps checking for the install and the
|
|
43
|
+
// device-flow tick. Past it the checklist stops promising a self-tick for device flow
|
|
44
|
+
// (the step says "turn it on later"); a fresh install report re-opens the window.
|
|
45
|
+
const VERIFY_WINDOW_MS = 2 * 60 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
// The one-trip install page for the app GitHub just created, carrying the attempt's state
|
|
48
|
+
// so GitHub hands it back on the setup_url redirect. null until the app's slug is known.
|
|
49
|
+
// PURE. (The callback page narrows it to the repo with /permissions?suggested_target_id
|
|
50
|
+
// when it can learn the numeric ids — see callback-page.js.)
|
|
51
|
+
function installUrlFor({ appSlug, state } = {}) {
|
|
52
|
+
if (!appSlug) return null;
|
|
53
|
+
const q = state ? `?state=${encodeURIComponent(state)}` : '';
|
|
54
|
+
return `https://github.com/apps/${encodeURIComponent(appSlug)}/installations/new${q}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// owner/repo from the instance's target_ref, or null. Strict — this string reaches a
|
|
58
|
+
// public read and the callback page's GitHub lookup.
|
|
59
|
+
const REPO_REF_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/;
|
|
60
|
+
function targetRepo(inst) {
|
|
61
|
+
const ref = String((inst && inst.target_ref) || '').trim().replace(/\.git$/, '');
|
|
62
|
+
return REPO_REF_RE.test(ref) ? ref : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The verified facts for one instance since `since` (the attempt's created_at): when each
|
|
66
|
+
// setup event was first seen, or null. One indexed read (idx_provisioning_events_instance).
|
|
67
|
+
async function githubSetupFacts(db, instanceId, since) {
|
|
68
|
+
const { rows } = await db.query(
|
|
69
|
+
`SELECT event, min(created_at) AS at FROM provisioning_events
|
|
70
|
+
WHERE instance_id = $1 AND event = ANY($2) AND created_at >= $3
|
|
71
|
+
GROUP BY event`,
|
|
72
|
+
[instanceId, Object.values(SETUP_EVENTS), since || new Date(0).toISOString()]);
|
|
73
|
+
const at = (name) => {
|
|
74
|
+
const r = rows.find((x) => x.event === name);
|
|
75
|
+
return r && r.at ? new Date(r.at).toISOString() : null;
|
|
76
|
+
};
|
|
77
|
+
return { installReportedAt: at(SETUP_EVENTS.installReported), installedAt: at(SETUP_EVENTS.installed), deviceFlowAt: at(SETUP_EVENTS.deviceFlow) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Each checklist step's state, from the latest attempt's EFFECTIVE status (manifest-truth's
|
|
81
|
+
// vocabulary) + the facts above. A closed vocabulary the page turns into words:
|
|
82
|
+
// 'done' · 'now' (waiting on GitHub, the founder or the platform) · 'todo' (not reached)
|
|
83
|
+
// · 'stopped' (this attempt failed at this step) · 'later' (device flow only: optional, off).
|
|
84
|
+
// PURE.
|
|
85
|
+
function githubChecklist(status, appSlug, facts = {}) {
|
|
86
|
+
const s = String(status || '');
|
|
87
|
+
const inFlight = s === 'pending' || s === 'code_received' || s === 'converting';
|
|
88
|
+
const failed = s === 'error' || s === 'expired';
|
|
89
|
+
const approve = appSlug ? 'done' : inFlight ? 'now' : failed ? 'stopped' : 'todo';
|
|
90
|
+
const install = facts.installedAt ? 'done' : appSlug ? 'now' : 'todo';
|
|
91
|
+
const signIn = s === 'done' ? 'done' : appSlug && failed ? 'stopped' : appSlug ? 'now' : 'todo';
|
|
92
|
+
const device = facts.deviceFlowAt ? 'done' : s === 'done' ? 'later' : 'todo';
|
|
93
|
+
return {
|
|
94
|
+
approve, install, sign_in: signIn, device_flow: device,
|
|
95
|
+
// what "is alive" waits for — device flow is optional by design
|
|
96
|
+
connected: approve === 'done' && install === 'done' && signIn === 'done',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Is the runner still checking this attempt? While it is, an install or a 'later'
|
|
101
|
+
// device-flow step can still tick itself, so the page keeps polling. PURE.
|
|
102
|
+
function stillVerifying(row, facts = {}, now = Date.now()) {
|
|
103
|
+
const settled = row && (row.resolved_at || row.updated_at);
|
|
104
|
+
const last = Math.max(settled ? new Date(settled).getTime() : 0, facts.installReportedAt ? new Date(facts.installReportedAt).getTime() : 0);
|
|
105
|
+
return last > 0 && now - last < VERIFY_WINDOW_MS;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// What the public status poll and the owner's instance read add for this leg: the checklist,
|
|
109
|
+
// the install link (until the install is verified), the repo it belongs on, and whether the
|
|
110
|
+
// runner is still checking. `status` is manifest-truth's EFFECTIVE status. Never the raw row.
|
|
111
|
+
// `withState` false (the owner's instance read) leaves the state token off the install
|
|
112
|
+
// link: that read never carries the token (task 1003235), and a stateless install still
|
|
113
|
+
// lands on the setup page, which sends the founder back to their projects.
|
|
114
|
+
// A failed facts read is "nothing verified yet" — the honest 'now' states, never a 500.
|
|
115
|
+
async function githubSetupRead(db, inst, row, status, { now = Date.now(), withState = true } = {}) {
|
|
116
|
+
if (!row) return { checklist: githubChecklist(null, null), install_url: null, repo: targetRepo(inst), verifying: false };
|
|
117
|
+
let facts = {};
|
|
118
|
+
// through module.exports so a route test can stub the one DB read on the cached module
|
|
119
|
+
try { facts = await module.exports.githubSetupFacts(db, row.instance_id, row.created_at); } catch (_) { facts = {}; }
|
|
120
|
+
const checklist = githubChecklist(status, row.app_slug, facts);
|
|
121
|
+
return {
|
|
122
|
+
checklist,
|
|
123
|
+
install_url: checklist.install === 'done' ? null : installUrlFor({ appSlug: row.app_slug, state: withState ? row.state : null }),
|
|
124
|
+
repo: targetRepo(inst),
|
|
125
|
+
verifying: stillVerifying(row, facts, now),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { SETUP_EVENTS, VERIFY_WINDOW_MS, installUrlFor, targetRepo, githubSetupFacts, githubChecklist, stillVerifying, githubSetupRead };
|
|
@@ -754,6 +754,9 @@ function buildGithubAppManifest({ instanceName, slug, instanceDomain, mothership
|
|
|
754
754
|
// is never called while inactive; point it at the mothership so it's a real host.
|
|
755
755
|
hook_attributes: { url: `${String(mothershipOrigin || '').replace(/\/+$/, '')}/api/bongos/provisioning/github-app/webhook`, active: false },
|
|
756
756
|
redirect_url: redirectUrl,
|
|
757
|
+
// One trip (task 1004649): after the install GitHub sends the founder here with the
|
|
758
|
+
// attempt's state, and the route hands them back to the project page.
|
|
759
|
+
setup_url: redirectUrl.replace(/\/callback$/, '/setup'), setup_on_update: true,
|
|
757
760
|
callback_urls: [
|
|
758
761
|
`${instOrigin}/api/gds/auth/web/callback`,
|
|
759
762
|
`${instOrigin}/api/bongos/auth/web/callback`,
|
|
@@ -82,13 +82,65 @@ const HANDBACK_SCRIPT = `
|
|
|
82
82
|
})();
|
|
83
83
|
`;
|
|
84
84
|
|
|
85
|
+
// Client script for the ONE-TRIP variant (task 1004649). GitHub just created the app and
|
|
86
|
+
// sent the founder here; the runner now turns GitHub's code into credentials and names the
|
|
87
|
+
// app. This page waits for that name on the state-keyed status poll, then sends the founder
|
|
88
|
+
// straight on to installing the app — narrowed to the project's own repo when GitHub will
|
|
89
|
+
// tell an anonymous caller the repo's numeric ids (a public repo; a private one falls back
|
|
90
|
+
// to the plain install page, where GitHub asks which account). GitHub's setup_url brings
|
|
91
|
+
// them back to the checklist afterwards. Already installed, or the attempt stopped → back
|
|
92
|
+
// to the checklist at once, which says what happened. A wait past five minutes stops and
|
|
93
|
+
// says so; the way back is always on the page. Same no-backtick / no-${...} rule as above.
|
|
94
|
+
const INSTALL_SCRIPT = `
|
|
95
|
+
(function(){
|
|
96
|
+
var cfg=window.__CB_SETUP__||{};
|
|
97
|
+
var back=cfg.back||'/projects?view=new';
|
|
98
|
+
if(cfg.state)back+='#ghstate='+encodeURIComponent(cfg.state);
|
|
99
|
+
var link=document.getElementById('back');if(link)link.href=back;
|
|
100
|
+
var note=document.getElementById('note');
|
|
101
|
+
var say=function(t){if(note)note.textContent=t;};
|
|
102
|
+
var tries=0,MAX=150;
|
|
103
|
+
function narrow(base,ownerId,repoId){
|
|
104
|
+
var parts=String(base).split('?');
|
|
105
|
+
return parts[0]+'/permissions?'+(parts[1]?parts[1]+'&':'')+'suggested_target_id='+encodeURIComponent(ownerId)+'&repository_ids[]='+encodeURIComponent(repoId);
|
|
106
|
+
}
|
|
107
|
+
function go(url){say('Opening the install page on GitHub…');location.replace(url);}
|
|
108
|
+
function toInstall(d){
|
|
109
|
+
if(!d.repo){go(d.install_url);return;}
|
|
110
|
+
fetch('https://api.github.com/repos/'+d.repo,{headers:{Accept:'application/vnd.github+json'}})
|
|
111
|
+
.then(function(r){return r.ok?r.json():null;})
|
|
112
|
+
.then(function(j){go(j&&j.id&&j.owner&&j.owner.id?narrow(d.install_url,j.owner.id,j.id):d.install_url);})
|
|
113
|
+
.catch(function(){go(d.install_url);});
|
|
114
|
+
}
|
|
115
|
+
function tick(){
|
|
116
|
+
tries++;
|
|
117
|
+
fetch('/api/bongos/provisioning/github-app/status?state='+encodeURIComponent(cfg.state||''))
|
|
118
|
+
.then(function(r){return r.ok?r.json():null;})
|
|
119
|
+
.then(function(d){
|
|
120
|
+
if(d&&d.checklist&&d.checklist.install==='done'){location.replace(back);return;}
|
|
121
|
+
if(d&&(d.status==='error'||d.status==='expired')){location.replace(back);return;}
|
|
122
|
+
if(d&&d.install_url){toInstall(d);return;}
|
|
123
|
+
again();
|
|
124
|
+
})
|
|
125
|
+
.catch(again);
|
|
126
|
+
}
|
|
127
|
+
function again(){
|
|
128
|
+
if(tries>=MAX){say('This is taking longer than usual. Go back to your project: the install step waits for you there.');return;}
|
|
129
|
+
setTimeout(tick,2000);
|
|
130
|
+
}
|
|
131
|
+
tick();
|
|
132
|
+
})();
|
|
133
|
+
`;
|
|
134
|
+
|
|
85
135
|
// The standalone landing page for the public GitHub redirect (the mothership serves it;
|
|
86
136
|
// it is NOT the hall shell). Two variants share the Cloud Bongos brand shell (task 2096):
|
|
87
137
|
// • hand-back — ok + state + domain: a brief "GitHub app created" that returns the
|
|
88
138
|
// founder to their setup checklist, where the wait is shown (task 1004548).
|
|
139
|
+
// • install — the hand-back's inputs + next:'install' (task 1004649): "GitHub app
|
|
140
|
+
// created", then straight on to installing it on the repo (INSTALL_SCRIPT).
|
|
89
141
|
// • message — everything else (errors + the no-domain success fallback): a calm badge
|
|
90
142
|
// + `body` (server-authored, trusted HTML).
|
|
91
|
-
function callbackPage({ ok, title, body, state, domain }) {
|
|
143
|
+
function callbackPage({ ok, title, body, state, domain, next }) {
|
|
92
144
|
const handback = !!(ok && state && domain);
|
|
93
145
|
const cfgJson = JSON.stringify({ state: state || '', domain: domain || '', back: '/projects?view=new' }).replace(/</g, '\\u003c');
|
|
94
146
|
const head = `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
@@ -98,6 +150,19 @@ function callbackPage({ ok, title, body, state, domain }) {
|
|
|
98
150
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
99
151
|
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Quicksand:wght@400;500;600;700&family=Fira+Code&display=swap" rel="stylesheet">
|
|
100
152
|
<style>${PAGE_STYLE}</style></head><body>`;
|
|
153
|
+
if (handback && next === 'install') {
|
|
154
|
+
return `${head}
|
|
155
|
+
<main class="card">
|
|
156
|
+
<div class="badge-warn">✓</div>
|
|
157
|
+
<h1>${esc(title)}</h1>
|
|
158
|
+
<p class="sub">Next, install it on your repo. Taking you there now.</p>
|
|
159
|
+
<p class="muted" id="note" role="status" aria-live="polite">Waiting for GitHub to hand over the app…</p>
|
|
160
|
+
<div class="cta"><a class="btn" id="back" href="/projects?view=new">Back to my project</a></div>
|
|
161
|
+
</main>
|
|
162
|
+
<script>window.__CB_SETUP__=${cfgJson};</script>
|
|
163
|
+
<script>${INSTALL_SCRIPT}</script>
|
|
164
|
+
</body></html>`;
|
|
165
|
+
}
|
|
101
166
|
if (handback) {
|
|
102
167
|
return `${head}
|
|
103
168
|
<main class="card">
|
|
@@ -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,
|