@bongos/core 1.25.20 → 1.25.22
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 +213 -78
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +18 -0
- package/clients/bongos-client/index.cjs +18 -0
- package/clients/bongos-client/index.d.ts +34 -8
- package/clients/bongos-client/index.mjs +18 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +18 -0
- package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
- package/docs/api/openapi.json +693 -125
- package/docs/api-reference.md +13 -4
- package/docs/copy-inventory.md +432 -427
- package/docs/copy-registry.json +571 -526
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +350 -344
- package/modules/government/board.js +8 -7
- package/modules/government/charter-advice.js +10 -3
- package/modules/government/charter.js +88 -4
- package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
- package/modules/government/routes/government.js +12 -12
- package/modules/government/sole-member.js +75 -0
- package/modules/hall-ui/public/genesis-home.js +12 -5
- package/modules/lifecycle/kickoff-checklist.js +1 -1
- package/modules/lifecycle/routes/pay-type-gates.js +7 -2
- package/modules/onboarding/genesis-home.js +14 -2
- package/modules/onboarding/project-facts.js +318 -0
- package/modules/onboarding/routes/genesis-work.js +16 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-standing.js +49 -0
- package/modules/platform-identity/routes/my-projects.js +8 -0
- package/modules/provisioning/grants.js +126 -0
- package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
- package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/project-facts.js +55 -0
- package/modules/provisioning/project-refusal.js +107 -0
- package/modules/provisioning/provisioning.js +6 -3
- package/modules/provisioning/render-lookup.js +36 -1
- package/modules/provisioning/render-promote.js +80 -0
- package/modules/provisioning/render-sandbox.js +6 -0
- package/modules/provisioning/route-helpers.js +70 -16
- package/modules/provisioning/routes/env-manifest.js +21 -5
- package/modules/provisioning/routes/grants.js +141 -0
- package/modules/provisioning/routes/provisioning.js +8 -7
- package/modules/provisioning/routes/render-standup.js +82 -3
- package/modules/public-landing/public/projects.html +290 -32
- package/package-lock.json +2 -2
- package/package.json +2 -1
- package/release-notes.json +32 -0
- package/scripts/bongos/genesis.js +49 -3
- package/scripts/bongos/government-walk-sole.js +41 -0
- package/scripts/bongos/government-walk.js +15 -16
- package/scripts/bongos/init.js +13 -0
- package/scripts/bongos/package-core.js +2 -0
- package/scripts/bongos/provision-render-common.js +48 -0
- package/scripts/bongos/provision-render-promote.js +93 -0
- package/scripts/bongos/provision-render-sandbox.js +19 -3
- package/scripts/bongos/provision-render.js +4 -36
- package/scripts/bongos/provision.js +1 -0
- package/scripts/bongos/render-api.js +17 -2
- package/scripts/bongos/render-payload.js +5 -2
- package/scripts/gds/government-walk-sole.js +6 -0
- package/scripts/gds/provision-render-common.js +6 -0
- package/scripts/gds/provision-render-promote.js +6 -0
- package/src/bongos/folder-upload.js +243 -0
- package/src/bongos/routes/github.js +109 -16
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +4 -0
- package/src/module-api.js +1 -1
- package/src/sandbox/gate.js +164 -0
- package/src/sandbox/index.js +144 -0
- package/tests/fleet_cost_view.mjs +2 -0
- package/tests/folder_upload_project.mjs +396 -0
- package/tests/genesis_government.mjs +5 -1
- package/tests/genesis_home.mjs +17 -0
- package/tests/genesis_work_routes.mjs +25 -1
- package/tests/government_charter_advice.mjs +49 -0
- package/tests/government_charter_forms.mjs +14 -3
- package/tests/government_seed.mjs +1 -0
- package/tests/government_sole_member.mjs +153 -0
- package/tests/project_catalog_routes.mjs +2 -0
- package/tests/project_facts.mjs +158 -0
- package/tests/projects_hub.mjs +1 -1
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_env_manifest.mjs +4 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
- package/tests/provisioning_project_grants.mjs +458 -0
- package/tests/provisioning_project_owner_gate.mjs +3 -1
- package/tests/provisioning_project_refusal.mjs +316 -0
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +2 -0
- package/tests/provisioning_settings.mjs +2 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/public_landing_projects.mjs +27 -5
- package/tests/render_promote.mjs +309 -0
- package/tests/render_sandbox.mjs +16 -8
- package/tests/sandbox_gate.mjs +182 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// modules/provisioning/routes/grants.js — "People with access": the owner gives a person access
|
|
2
|
+
// to help run their hosted project, lists it, and takes it back (task 1004161, ADR 0407 §2).
|
|
3
|
+
//
|
|
4
|
+
// The grant itself is provisioning_project_grants, written only by ../grants.js. Who may do
|
|
5
|
+
// what here:
|
|
6
|
+
// • GIVE — the project's owner only, from their own session. NO archon override: an archon
|
|
7
|
+
// already reaches the project for fleet support, and a grant would let them hand someone
|
|
8
|
+
// else's project to a third person without its owner (owner decision, ADR 0407).
|
|
9
|
+
// • LIST — the owner, or an archon (the ordinary owner-or-archon gate).
|
|
10
|
+
// • TAKE BACK — the owner, the grantee themselves (leaving), or an archon. A revoke only ever
|
|
11
|
+
// removes access, so the archon door here is a safety lever, not a widening.
|
|
12
|
+
// Every give and take-back writes a provisioning_events row naming who did it.
|
|
13
|
+
//
|
|
14
|
+
// Its own routes file (declared in module.json) because routes/provisioning.js sits at the
|
|
15
|
+
// size ratchet.
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const express = require('express');
|
|
19
|
+
const api = require('../../../src/module-api');
|
|
20
|
+
const { pool, validateOrRespond, parseId } = api;
|
|
21
|
+
const provisioning = require('../provisioning');
|
|
22
|
+
const grants = require('../grants');
|
|
23
|
+
const { failFrom } = require('../public-refusal');
|
|
24
|
+
const { requireProjectOwner, projectActor, projectActorLabel, isProjectOwner } = require('../route-helpers');
|
|
25
|
+
|
|
26
|
+
const log = api.logger('provisioning');
|
|
27
|
+
const loadInstance = (id) => provisioning.getInstanceById(pool, id);
|
|
28
|
+
|
|
29
|
+
const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
30
|
+
|
|
31
|
+
function publicGrant(g) {
|
|
32
|
+
return {
|
|
33
|
+
id: String(g.id),
|
|
34
|
+
level: g.level,
|
|
35
|
+
granted_at: g.granted_at,
|
|
36
|
+
builder: g.github_login === undefined ? { id: String(g.grantee_builder_id) } : {
|
|
37
|
+
id: String(g.grantee_builder_id), github_login: g.github_login,
|
|
38
|
+
display_name: g.display_name || null, avatar_url: g.avatar_url || null,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = function buildGrantsRouter() {
|
|
44
|
+
const router = express.Router();
|
|
45
|
+
|
|
46
|
+
// GET /provisioning/instances/:id/grants — the project's live grants.
|
|
47
|
+
// rank: any authenticated builder, own resource (owner; archon may read any). 404 otherwise.
|
|
48
|
+
router.get('/provisioning/instances/:id/grants', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
49
|
+
try {
|
|
50
|
+
const rows = await grants.listLiveGrants(pool, req.project.id);
|
|
51
|
+
res.json({ grants: rows.map(publicGrant), levels: grants.GRANT_LEVELS, limit: grants.MAX_LIVE_GRANTS });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log.error('[provisioning] GET /provisioning/instances/:id/grants', err);
|
|
54
|
+
failFrom(res, err, 'grants_read_failed');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// POST /provisioning/instances/:id/grants { github_login } — give a person access.
|
|
59
|
+
// rank: any authenticated builder, own resource, OWNER ONLY (no archon override). 404 for
|
|
60
|
+
// anyone else, so a stranger learns nothing about the id.
|
|
61
|
+
router.post('/provisioning/instances/:id/grants', api.requireBuilder, requireProjectOwner(loadInstance, { archonOverride: false }), async (req, res) => {
|
|
62
|
+
if (validateOrRespond(req, res, {
|
|
63
|
+
github_login: { required: true, type: 'string', maxLength: 39 },
|
|
64
|
+
})) return;
|
|
65
|
+
const login = String(req.body.github_login).trim().replace(/^@/, '');
|
|
66
|
+
if (!GITHUB_LOGIN.test(login)) {
|
|
67
|
+
return res.fail('bad_github_login', { status: 400, message: 'That is not a GitHub username.' });
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const inst = req.project;
|
|
71
|
+
const person = await grants.findGranteeByLogin(pool, login);
|
|
72
|
+
if (!person) {
|
|
73
|
+
return res.fail('grantee_not_found', { status: 404,
|
|
74
|
+
message: `Nobody called ${login} has signed in to the hub yet. Ask them to sign in once, then try again.` });
|
|
75
|
+
}
|
|
76
|
+
const out = await grants.grantProjectAccess(pool, {
|
|
77
|
+
instanceId: inst.id, granteeBuilderId: person.id, level: 'operate', grantedByBuilderId: req.builder.id,
|
|
78
|
+
});
|
|
79
|
+
if (out.refused === 'grantee_is_owner') {
|
|
80
|
+
return res.fail('grantee_is_owner', { status: 400, message: 'You own this project already.' });
|
|
81
|
+
}
|
|
82
|
+
if (out.refused === 'grant_limit') {
|
|
83
|
+
return res.fail('grant_limit', { status: 409,
|
|
84
|
+
message: `A project can give access to at most ${grants.MAX_LIVE_GRANTS} people. Take someone off first.` });
|
|
85
|
+
}
|
|
86
|
+
if (out.refused) return res.fail('instance_not_found', 404);
|
|
87
|
+
if (out.created) {
|
|
88
|
+
await provisioning.recordEvent(pool, {
|
|
89
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'grant-made',
|
|
90
|
+
detail: `access (${out.grant.level}) given to builder ${person.id} (${person.github_login}) by ${projectActorLabel(req)}`,
|
|
91
|
+
actor: projectActor(req),
|
|
92
|
+
}).catch(() => {});
|
|
93
|
+
}
|
|
94
|
+
res.status(out.created ? 201 : 200).json({
|
|
95
|
+
ok: true, created: !!out.created,
|
|
96
|
+
grant: publicGrant({ ...out.grant, ...person, grantee_builder_id: person.id }),
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log.error('[provisioning] POST /provisioning/instances/:id/grants', err);
|
|
100
|
+
failFrom(res, err, 'grant_failed');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// DELETE /provisioning/instances/:id/grants/:grantId — take access back.
|
|
105
|
+
// rank: any authenticated builder: the project's owner, an archon, or the grantee removing
|
|
106
|
+
// their OWN grant — decided in the handler, because "whose grant is it" is a fact of the row.
|
|
107
|
+
// Anyone else gets the same 404 a missing project gets.
|
|
108
|
+
router.delete('/provisioning/instances/:id/grants/:grantId', api.requireBuilder, async (req, res) => {
|
|
109
|
+
const id = parseId(req, res);
|
|
110
|
+
if (!id) return;
|
|
111
|
+
const grantId = Number(req.params.grantId);
|
|
112
|
+
if (!Number.isSafeInteger(grantId) || grantId <= 0) return res.fail('bad_id', 400);
|
|
113
|
+
try {
|
|
114
|
+
const inst = await loadInstance(id);
|
|
115
|
+
if (!inst) return res.fail('instance_not_found', 404);
|
|
116
|
+
const grant = await grants.getGrant(pool, inst.id, grantId);
|
|
117
|
+
const isOwner = isProjectOwner(inst, req.builder);
|
|
118
|
+
const isArchon = req.builder.rank === 'archon';
|
|
119
|
+
const isOwnGrant = !!grant && String(grant.grantee_builder_id) === String(req.builder.id);
|
|
120
|
+
if (!isOwner && !isArchon && !isOwnGrant) return res.fail('instance_not_found', 404);
|
|
121
|
+
if (!grant || grant.revoked_at) {
|
|
122
|
+
return res.fail('grant_not_found', { status: 404, message: 'That access was already taken back.' });
|
|
123
|
+
}
|
|
124
|
+
const revoked = await grants.revokeProjectAccess(pool, { instanceId: inst.id, grantId, revokedByBuilderId: req.builder.id });
|
|
125
|
+
if (!revoked) return res.fail('grant_not_found', { status: 404, message: 'That access was already taken back.' });
|
|
126
|
+
req.project = inst;
|
|
127
|
+
req.projectDoor = isOwner ? 'owner' : (isOwnGrant ? 'grantee' : 'archon');
|
|
128
|
+
await provisioning.recordEvent(pool, {
|
|
129
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'grant-revoked',
|
|
130
|
+
detail: `access (${revoked.level}) of builder ${revoked.grantee_builder_id} taken back by ${projectActorLabel(req)}`,
|
|
131
|
+
actor: projectActor(req),
|
|
132
|
+
}).catch(() => {});
|
|
133
|
+
res.json({ ok: true, revoked: true });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
log.error('[provisioning] DELETE /provisioning/instances/:id/grants/:grantId', err);
|
|
136
|
+
failFrom(res, err, 'grant_revoke_failed');
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return router;
|
|
141
|
+
};
|
|
@@ -68,7 +68,8 @@ const { callbackPage } = require('./callback-page'); const githubSetup = require
|
|
|
68
68
|
const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth'); const testProject = require('../test-project'); const guideHandoff = require('../guide-handoff');
|
|
69
69
|
const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
|
|
70
70
|
const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
|
|
71
|
-
const { requireProjectOwner, projectActor, isProjectOwner } = require('../route-helpers');
|
|
71
|
+
const { requireProjectOwner, projectActor, projectActorLabel, isProjectOwner, grantedView } = require('../route-helpers');
|
|
72
|
+
const GRANTEE = { grantee: 'operate' }; // the three routes here a granted helper may use (task 1004161, ADR 0407 §3) // the owner-or-archon gate (task 1004159, ADR 0328)
|
|
72
73
|
// the kernel ports this module provides (moved out at the size ratchet, task 1003578)
|
|
73
74
|
const { registerProvisioningSeams } = require('../seams');
|
|
74
75
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
@@ -658,7 +659,7 @@ module.exports = function provisioningRoutes() {
|
|
|
658
659
|
// GET /provisioning/instances/:id — read one instance (the manage page's read).
|
|
659
660
|
// rank: any authenticated builder (own resource; archon may read any — task
|
|
660
661
|
// 1003041). 404 otherwise — does not leak another owner's instance existence.
|
|
661
|
-
router.get('/provisioning/instances/:id', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
662
|
+
router.get('/provisioning/instances/:id', auth.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
|
|
662
663
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
663
664
|
if (!id) return;
|
|
664
665
|
try {
|
|
@@ -676,7 +677,7 @@ module.exports = function provisioningRoutes() {
|
|
|
676
677
|
// panel recovers it from server state after a reload — not from sessionStorage.
|
|
677
678
|
const manifest = await provisioning.getLatestOAuthManifestForInstance(pool, id);
|
|
678
679
|
const githubSetupNow = await githubSetup.githubSetupRead(pool, inst, manifest, manifest ? provisioning.publicGithubApp(manifest).status : null, { withState: false });
|
|
679
|
-
res.json({
|
|
680
|
+
res.json(grantedView(req, {
|
|
680
681
|
// the standing install nudge rides the instance, as on the list read (task 1004763)
|
|
681
682
|
instance: { ...ownerInstance(inst), github_install: githubSetup.installNudge(githubSetupNow) },
|
|
682
683
|
open_intent: open ? { action: open.action, state: open.state } : null,
|
|
@@ -684,7 +685,7 @@ module.exports = function provisioningRoutes() {
|
|
|
684
685
|
// the four-step checklist from what the runner verified (task 1004649) — the
|
|
685
686
|
// install link rides WITHOUT the state token this read never carries
|
|
686
687
|
github_setup: githubSetupNow,
|
|
687
|
-
});
|
|
688
|
+
}));
|
|
688
689
|
} catch (err) {
|
|
689
690
|
log.error('[provisioning] GET /provisioning/instances/:id', err);
|
|
690
691
|
failFrom(res, err, 'read_failed');
|
|
@@ -874,7 +875,7 @@ module.exports = function provisioningRoutes() {
|
|
|
874
875
|
// GET /provisioning/instances/:id/settings — read a project's effective
|
|
875
876
|
// settings + the allowed values (so a form can render its choices).
|
|
876
877
|
// rank: any authenticated builder (own resource; archon may read any).
|
|
877
|
-
router.get('/provisioning/instances/:id/settings', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
878
|
+
router.get('/provisioning/instances/:id/settings', auth.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
|
|
878
879
|
try {
|
|
879
880
|
const inst = req.project;
|
|
880
881
|
// `planet` rides beside `settings` (task 1003347): the same row, read by a
|
|
@@ -1038,7 +1039,7 @@ module.exports = function provisioningRoutes() {
|
|
|
1038
1039
|
// Every refusal names the action that IS available in that state rather than
|
|
1039
1040
|
// hiding behind a code, and no branch promises a duration — a restart's downtime
|
|
1040
1041
|
// is exactly what this route cannot know (the F16 rule, ADR 0190 §7).
|
|
1041
|
-
router.post('/provisioning/instances/:id/restart', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
1042
|
+
router.post('/provisioning/instances/:id/restart', auth.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
|
|
1042
1043
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
1043
1044
|
if (!id) return;
|
|
1044
1045
|
try {
|
|
@@ -1069,7 +1070,7 @@ module.exports = function provisioningRoutes() {
|
|
|
1069
1070
|
if (r.created) {
|
|
1070
1071
|
await provisioning.recordEvent(pool, {
|
|
1071
1072
|
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'restart',
|
|
1072
|
-
detail:
|
|
1073
|
+
detail: `restart requested by ${projectActorLabel(req)}`, actor,
|
|
1073
1074
|
}).catch(() => {});
|
|
1074
1075
|
}
|
|
1075
1076
|
res.json({ ok: true, queued: r.created, action: 'restart', status: inst.status, message: r.created
|
|
@@ -23,7 +23,8 @@ const { pool, validateOrRespond, parseId } = api;
|
|
|
23
23
|
const provisioning = require('../provisioning');
|
|
24
24
|
const renderStandup = require('../render-standup');
|
|
25
25
|
const { failFrom } = require('../public-refusal');
|
|
26
|
-
const { lookupRender, RenderLookupError } = require('../render-lookup');
|
|
26
|
+
const { lookupRender, lookupPromotion, RenderLookupError } = require('../render-lookup');
|
|
27
|
+
const renderPromote = require('../render-promote');
|
|
27
28
|
const { appStatus } = require('../app-status');
|
|
28
29
|
const renderSandbox = require('../render-sandbox');
|
|
29
30
|
const envManifest = require('../env-manifest');
|
|
@@ -46,6 +47,10 @@ const LOOKUP_STATUS = { render_key_refused: 422, render_workspace_unreachable: 4
|
|
|
46
47
|
// OWNER ONLY, no archon override, for every Render route: the key and the account it acts
|
|
47
48
|
// in are the owner's. A missing project and someone else's answer the same 404, so an id is
|
|
48
49
|
// never confirmed.
|
|
50
|
+
// One sentence for "another run holds this project, so the key was dropped" — the sandbox
|
|
51
|
+
// and promote routes say it the same way (voice.md V1: one fixed sentence per concept).
|
|
52
|
+
const BUSY_KEY_NOT_KEPT = 'Something else is being done to this project right now, so your key was not kept. Try again in a minute.';
|
|
53
|
+
|
|
49
54
|
function ownedBy(inst, builder) { return !!(inst && builder && String(inst.owner_builder_id) === String(builder.id)); }
|
|
50
55
|
|
|
51
56
|
module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
|
|
@@ -105,7 +110,9 @@ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
|
|
|
105
110
|
: [];
|
|
106
111
|
// The sandbox beside it (ADR 0406, task 1005289), in the same shape.
|
|
107
112
|
const sandbox = renderSandbox.sandboxStatus(inst);
|
|
108
|
-
|
|
113
|
+
// Promoting from it (ADR 0406 §2, task 1005290): whether the owner can, and the record.
|
|
114
|
+
const promote = { ...renderPromote.promoteView(inst), recent: await renderPromote.listPromotions(pool, id) };
|
|
115
|
+
res.json({ ok: true, app, sandbox, promote, env_vars: envVars, values_held: false });
|
|
109
116
|
} catch (err) {
|
|
110
117
|
log.error('[provisioning] GET /provisioning/instances/:id/render', err && err.message);
|
|
111
118
|
failFrom(res, err, 'render_status_failed');
|
|
@@ -139,7 +146,7 @@ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
|
|
|
139
146
|
const r = await renderSandbox.enqueueSandboxAdd(pool, id, { keyEnc: api.secretBox.encrypt(req.body.key), requestedBy: 'api:self' });
|
|
140
147
|
if (r.conflict) {
|
|
141
148
|
return res.fail('intent_conflict', { status: 409,
|
|
142
|
-
message:
|
|
149
|
+
message: BUSY_KEY_NOT_KEPT,
|
|
143
150
|
details: { action: r.intent.action, state: r.intent.state } });
|
|
144
151
|
}
|
|
145
152
|
await provisioning.recordEvent(pool, {
|
|
@@ -155,6 +162,78 @@ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
|
|
|
155
162
|
}
|
|
156
163
|
});
|
|
157
164
|
|
|
165
|
+
// POST /provisioning/instances/:id/render/promote/look — rank: any authenticated builder,
|
|
166
|
+
// and OWNER ONLY. What is waiting on the sandbox to go live (ADR 0406 §2, task 1005290):
|
|
167
|
+
// the change the live app runs, and every change that has run on the sandbox since, newest
|
|
168
|
+
// first. A read-only look at Render on the owner's key held for this one request (ADR 0348
|
|
169
|
+
// as widened by ADR 0406 §11): GET only, never stored, logged or sent back.
|
|
170
|
+
router.post('/provisioning/instances/:id/render/promote/look', api.requireBuilder, lookupLimit, async (req, res) => {
|
|
171
|
+
const id = parseId(req, res, { positiveInt: true });
|
|
172
|
+
if (!id) return;
|
|
173
|
+
if (validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: 200 } })) return;
|
|
174
|
+
try {
|
|
175
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
176
|
+
if (!ownedBy(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
177
|
+
if (!renderStandup.KEY_RE.test(req.body.key)) return res.fail('render_request_invalid', { status: 400, message: 'That doesn’t look like a Render API key. It starts with “rnd_”.' });
|
|
178
|
+
const why = renderPromote.noPromoteReason(inst);
|
|
179
|
+
if (why) return res.fail('promote_unavailable', { status: 409, message: why });
|
|
180
|
+
const h = inst.app_host;
|
|
181
|
+
const out = await lookupPromotion(req.body.key, { ownerId: h.ownerId, liveServiceId: h.serviceId, sandboxServiceId: h.sandbox.serviceId, fetchImpl: fetchImpl || globalThis.fetch });
|
|
182
|
+
res.json({ ok: true, ...out });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err instanceof RenderLookupError) return res.fail(err.code, { status: LOOKUP_STATUS[err.code] || 502, message: err.message });
|
|
185
|
+
log.error('[provisioning] POST /provisioning/instances/:id/render/promote/look', err && err.message);
|
|
186
|
+
failFrom(res, err, 'render_lookup_failed');
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// POST /provisioning/instances/:id/render/promote — rank: any authenticated builder, and
|
|
191
|
+
// OWNER ONLY: the key and the live app are the owner's, so the server decides only the
|
|
192
|
+
// owner may move it (an archon on someone else's project gets the stranger's 404). Takes
|
|
193
|
+
// the change the owner chose (`commit_id`) and the change live ran when they looked
|
|
194
|
+
// (`expect_live_commit`); the runner refuses if either moved. This is the ONLY way Bongos
|
|
195
|
+
// moves a live app that has a sandbox, and the reply is a receipt: nothing is live yet.
|
|
196
|
+
router.post('/provisioning/instances/:id/render/promote', api.requireBuilder, sandboxLimit, async (req, res) => {
|
|
197
|
+
const id = parseId(req, res, { positiveInt: true });
|
|
198
|
+
if (!id) return;
|
|
199
|
+
if (validateOrRespond(req, res, {
|
|
200
|
+
key: { required: true, type: 'string', maxLength: 200 },
|
|
201
|
+
commit_id: { required: true, type: 'string', maxLength: 64 },
|
|
202
|
+
expect_live_commit: { type: 'string', maxLength: 64 },
|
|
203
|
+
})) return;
|
|
204
|
+
try {
|
|
205
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
206
|
+
if (!ownedBy(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
207
|
+
if (!renderStandup.KEY_RE.test(req.body.key)) return res.fail('render_request_invalid', { status: 400, message: 'That doesn’t look like a Render API key. It starts with “rnd_”.' });
|
|
208
|
+
const commit = String(req.body.commit_id).trim().toLowerCase();
|
|
209
|
+
const expect = req.body.expect_live_commit == null ? null : String(req.body.expect_live_commit).trim().toLowerCase();
|
|
210
|
+
if (!renderPromote.COMMIT_RE.test(commit) || (expect !== null && !renderPromote.COMMIT_RE.test(expect))) {
|
|
211
|
+
return res.fail('promote_request_invalid', { status: 400, message: 'Choose one of the changes waiting on your sandbox.' });
|
|
212
|
+
}
|
|
213
|
+
const why = renderPromote.noPromoteReason(inst);
|
|
214
|
+
if (why) return res.fail('promote_unavailable', { status: 409, message: why });
|
|
215
|
+
if (!api.secretBox.isConfigured()) {
|
|
216
|
+
return res.fail('render_unavailable', { status: 503, message: 'The platform can’t take a key safely right now, so nothing was sent. Try again later.' });
|
|
217
|
+
}
|
|
218
|
+
const r = await renderPromote.enqueuePromote(pool, id, { keyEnc: api.secretBox.encrypt(req.body.key), commitId: commit, expectLiveCommit: expect, requestedBy: req.builder.id });
|
|
219
|
+
if (r.conflict) {
|
|
220
|
+
return res.fail('intent_conflict', { status: 409,
|
|
221
|
+
message: BUSY_KEY_NOT_KEPT,
|
|
222
|
+
details: { action: r.intent.action, state: r.intent.state } });
|
|
223
|
+
}
|
|
224
|
+
await provisioning.recordEvent(pool, {
|
|
225
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'render-promote',
|
|
226
|
+
detail: `promote of ${commit.slice(0, 7)} to the live app requested by owner`, actor: 'api:self',
|
|
227
|
+
}).catch(() => {});
|
|
228
|
+
res.json({ ok: true, queued: true, action: 'render-promote',
|
|
229
|
+
message: 'Queued. That change goes to your live app in a moment; Render then builds it, which usually takes a few minutes. Your key is used once and then thrown away.' });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
// Never log the request: it carries the key.
|
|
232
|
+
log.error('[provisioning] POST /provisioning/instances/:id/render/promote', err && err.message);
|
|
233
|
+
failFrom(res, err, 'render_promote_failed');
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
158
237
|
// POST /provisioning/instances/:id/render — rank: any authenticated builder, and OWNER
|
|
159
238
|
// ONLY: the key is the owner's and so is the account it acts in, so an archon on
|
|
160
239
|
// someone else's project gets the same 404 as a stranger. The reply is a receipt;
|