@bongos/core 1.19.1080 → 1.20.1
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 +573 -188
- package/.claude/skills/planning-session/SKILL.md +6 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +34 -0
- package/clients/bongos-client/index.cjs +34 -0
- package/clients/bongos-client/index.d.ts +53 -4
- package/clients/bongos-client/index.mjs +34 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
- package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +2 -0
- package/docs/adr/0347-every-store-module-ships-a-how-to.md +135 -0
- package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
- package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
- package/docs/adr/README.md +4 -0
- package/docs/api/openapi.json +995 -40
- package/docs/api-reference.md +33 -8
- package/docs/architecture.md +6 -2
- package/docs/copy-inventory.md +620 -553
- package/docs/copy-registry.json +1467 -820
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +8 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +1674 -1566
- package/migrations/core_260_goals_working_area.sql +63 -0
- package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
- package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
- package/modules/agents/lib/validate.js +43 -0
- package/modules/autonomy/gauge.js +38 -2
- package/modules/grading/grader-subagent.js +37 -2
- package/modules/grading/grader-workers/reader.js +65 -5
- package/modules/hall-ui/public/approval-queue.css +33 -7
- package/modules/hall-ui/public/approval-queue.js +9 -2
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/brand-holes.js +121 -0
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/collab.js +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +6 -1
- package/modules/hall-ui/public/deploy.js +22 -8
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/dom-utils.js +20 -0
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/idea.js +28 -0
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/ideas.js +19 -3
- package/modules/hall-ui/public/index.html +5 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile-nudge.js +21 -4
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +19 -30
- package/modules/hall-ui/public/settings.js +24 -177
- package/modules/hall-ui/public/settings.states.json +1 -1
- package/modules/hall-ui/public/shell.js +4 -2
- package/modules/hall-ui/public/studio.css +14 -8
- package/modules/hall-ui/public/studio.html +5 -4
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +6 -3
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
- package/modules/hall-ui/public/tweak-editor.css +142 -11
- package/modules/hall-ui/public/tweak-editor.html +25 -9
- package/modules/hall-ui/public/tweak-editor.js +243 -10
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/approval-queue.md +6 -0
- package/modules/hall-ui/records/tweak-editor.md +6 -0
- package/modules/ideas/ratify.js +36 -8
- package/modules/ideas/routes/ratify-goal.js +6 -0
- package/modules/lifecycle/db-analytics.js +79 -10
- package/modules/lifecycle/db-claims.js +48 -1
- package/modules/lifecycle/db-deps-criteria.js +94 -40
- package/modules/lifecycle/db-goals.js +4 -1
- package/modules/lifecycle/db-grade.js +9 -1
- package/modules/lifecycle/db-relevance-flags.js +183 -0
- package/modules/lifecycle/db-versions.js +4 -3
- package/modules/lifecycle/db.js +19 -0
- package/modules/lifecycle/done-when.js +5 -3
- package/modules/lifecycle/est-advisory.js +31 -4
- package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
- package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
- package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +8 -1
- package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
- package/modules/lifecycle/routes/goals.js +1 -1
- package/modules/lifecycle/routes/tasks.js +27 -24
- package/modules/lifecycle/routes/visuals.js +9 -0
- package/modules/lifecycle/ship-preflight.js +7 -11
- package/modules/lifecycle/task-visuals.js +109 -5
- package/modules/npm-release/module.json +2 -1
- package/modules/npm-release/preview/commands.js +81 -0
- package/modules/npm-release/preview/divert.js +95 -0
- package/modules/npm-release/preview/env.js +78 -0
- package/modules/npm-release/preview/proxy.js +107 -0
- package/modules/npm-release/preview/runtime.js +83 -0
- package/modules/npm-release/preview/supervisor.js +175 -0
- package/modules/npm-release/public/work.js +65 -1
- package/modules/npm-release/routes/preview.js +133 -0
- package/modules/provisioning/app-status.js +83 -0
- package/modules/provisioning/migrations/provisioning_028_render_standup.sql +46 -0
- package/modules/provisioning/module.json +4 -2
- package/modules/provisioning/pollers/app-liveness.js +110 -0
- package/modules/provisioning/provisioning.js +5 -5
- package/modules/provisioning/render-lookup.js +68 -0
- package/modules/provisioning/render-standup.js +134 -0
- package/modules/provisioning/routes/render-standup.js +165 -0
- package/modules/provisioning/starter-bundles.js +4 -1
- package/modules/public-landing/public/account-private.states.json +14 -0
- package/modules/public-landing/public/account.html +513 -0
- package/modules/public-landing/public/account.probes.json +37 -0
- package/modules/public-landing/public/account.states.json +16 -0
- package/modules/public-landing/public/index.html +5 -2
- package/modules/public-landing/public/projects.html +661 -18
- package/modules/public-landing/public/projects.states.json +8 -1
- package/modules/render-deploy/deploys.js +203 -0
- package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
- package/modules/render-deploy/module.json +25 -0
- package/modules/render-deploy/public/deploy.css +10 -0
- package/modules/render-deploy/public/deploy.js +249 -0
- package/modules/render-deploy/render.js +56 -0
- package/modules/render-deploy/routes/act.js +88 -0
- package/modules/render-deploy/routes/app.js +65 -0
- package/modules/render-deploy/routes/history.js +38 -0
- package/modules/specialities/db.js +27 -8
- package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
- package/modules/specialities/routes/specialities.js +51 -3
- package/modules/specialities/skills.js +94 -0
- package/modules/specialities/specialities.js +56 -9
- package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
- package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
- package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
- package/modules/ui-design/kit/lib.js +3 -1
- package/modules/ui-design/kit/serve.js +96 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +173 -0
- package/scripts/gds/agents-sync.js +13 -3
- package/scripts/gds/autobongos-run.js +80 -2
- package/scripts/gds/autobongos-service.cmd +12 -0
- package/scripts/gds/copy-apply.js +14 -0
- package/scripts/gds/dev-box-guard.js +3 -2
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/grade-correlation-audit.js +42 -12
- package/scripts/gds/grade-replay.js +1 -1
- package/scripts/gds/provision-core-upgrade.js +38 -7
- package/scripts/gds/provision-pin-key.js +176 -0
- package/scripts/gds/provision-render.js +189 -0
- package/scripts/gds/provision-teardown.js +9 -2
- package/scripts/gds/provision-units.js +31 -0
- package/scripts/gds/provision.js +12 -12
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/render-api.js +197 -0
- package/scripts/gds/render-payload.js +84 -0
- package/scripts/gds/run-unit-tests.js +10 -0
- package/scripts/gds/ship-finish.js +19 -20
- package/scripts/gds/smoke-dependencies.sh +37 -8
- package/scripts/gds/status.js +12 -4
- package/scripts/gds/upgrade.js +2 -2
- package/scripts/public-mirror-export.js +7 -1
- package/src/bongos/module-scope-map.js +13 -0
- package/src/bongos/serve-internal.js +3 -1
- package/src/module-api.js +10 -1
- package/src/platform-server.js +39 -0
- package/tests/account_privacy_flags.mjs +10 -6
- package/tests/agents_authoring.mjs +15 -1
- package/tests/agents_sync.mjs +106 -5
- package/tests/agents_validate.mjs +45 -0
- package/tests/api_path_404.mjs +6 -0
- package/tests/autobongos_cadence.mjs +7 -0
- package/tests/autobongos_loop.mjs +136 -1
- package/tests/autonomy_gauge.mjs +62 -0
- package/tests/blocker_hall_live_proof.mjs +275 -0
- package/tests/claim_gate_ci_unblock.mjs +116 -0
- package/tests/claim_gate_rebase.mjs +15 -3
- package/tests/collab_page.mjs +10 -0
- package/tests/conductor_main_e2e.mjs +97 -0
- package/tests/core_262_drop_box_blocked_db.mjs +143 -0
- package/tests/core_upgrade_runner.mjs +1 -0
- package/tests/dependency_writes_atomic.mjs +191 -0
- package/tests/deploy_page_projects.mjs +2 -1
- package/tests/effective_visibility_predicate.mjs +15 -0
- package/tests/est_advisory.mjs +27 -0
- package/tests/goal_map_page.mjs +26 -0
- package/tests/goal_task_relevance.mjs +518 -0
- package/tests/goal_working_area.mjs +146 -0
- package/tests/grade_attempts.mjs +61 -0
- package/tests/grade_attribution.mjs +23 -0
- package/tests/grade_correlation_audit.mjs +54 -1
- package/tests/grader_reader_lens.mjs +55 -2
- package/tests/grader_root_outage.mjs +6 -1
- package/tests/grader_subagent_tools_arg.mjs +102 -0
- package/tests/hall_approval_queue.mjs +54 -2
- package/tests/hall_settings_world.mjs +3 -1
- package/tests/hall_tweak_editor.mjs +316 -11
- package/tests/hub_account_page.mjs +394 -0
- package/tests/idea_detail_page.mjs +64 -0
- package/tests/idea_goal_ratification.mjs +64 -0
- package/tests/idea_spark_hall.mjs +60 -9
- package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
- package/tests/landing_page.mjs +6 -3
- package/tests/module-scope-map.mjs +31 -8
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/no_phantom_mirror_workflow.mjs +24 -0
- package/tests/npm_release_preview_commands.mjs +89 -0
- package/tests/npm_release_preview_divert.mjs +164 -0
- package/tests/npm_release_preview_env.mjs +100 -0
- package/tests/npm_release_preview_proxy.mjs +162 -0
- package/tests/npm_release_preview_routes.mjs +208 -0
- package/tests/npm_release_preview_supervisor.mjs +203 -0
- package/tests/pin_write_key.mjs +383 -0
- package/tests/planning_session_skill.mjs +27 -0
- package/tests/platform_boot.mjs +71 -0
- package/tests/profile_nudge_links.mjs +86 -0
- package/tests/profile_ui_cross_project.mjs +1 -1
- package/tests/projects_hub.mjs +2 -2
- package/tests/projects_hub_app_status.mjs +249 -0
- package/tests/projects_hub_app_step.mjs +282 -52
- package/tests/projects_hub_render_connect.mjs +295 -0
- package/tests/provision_render.mjs +362 -0
- package/tests/provision_settings_apply.mjs +62 -0
- package/tests/provisioning_app_status.mjs +204 -0
- package/tests/provisioning_render_route.mjs +336 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/render_api.mjs +153 -0
- package/tests/render_check.mjs +2 -1
- package/tests/render_deploy.mjs +495 -0
- package/tests/ship_preflight.mjs +61 -8
- package/tests/smoke_dependencies_witness.mjs +46 -0
- package/tests/speciality_skills.mjs +214 -0
- package/tests/studio_room_height.mjs +70 -0
- package/tests/task_detail_includes.mjs +10 -0
- package/tests/task_visuals_instance_root.mjs +150 -0
- package/tests/tweak_batch_apply.mjs +21 -0
- package/tests/ui_design_kit.mjs +36 -4
- package/tests/upgrade.mjs +1 -1
- package/tests/upgrade_persist_pin.mjs +24 -0
|
@@ -57,8 +57,11 @@ const INDEPENDENT_KAPPA = 0.4;
|
|
|
57
57
|
// A worker's vote on one grade: 1 objects, 0 clears, null no signal.
|
|
58
58
|
function voteOf(w) {
|
|
59
59
|
if (!w || typeof w !== 'object') return null;
|
|
60
|
-
|
|
61
|
-
const
|
|
60
|
+
// A stored attempt keeps severities only (task 1004292); a full grade keeps issues.
|
|
61
|
+
const severities = Array.isArray(w.severities)
|
|
62
|
+
? w.severities
|
|
63
|
+
: (Array.isArray(w.issues) ? w.issues : []).map((i) => i && i.severity);
|
|
64
|
+
const substantive = severities.some((sv) => sv === 'blocker' || sv === 'major');
|
|
62
65
|
if (w.error && !substantive) return null;
|
|
63
66
|
if (w.verdict === 'pass') return 0;
|
|
64
67
|
if (w.verdict === 'concerns' || w.verdict === 'fail') return 1;
|
|
@@ -148,23 +151,47 @@ function classify(kappa) {
|
|
|
148
151
|
// One stored grade → the slim row the audit reads (review prose hashed, as in
|
|
149
152
|
// the replay corpus): { task_id, task_status, passed, workers }, or null when
|
|
150
153
|
// it is not a panel grade.
|
|
154
|
+
//
|
|
155
|
+
// task 1004292: grade.attempts (every round that kept its verdicts, oldest first,
|
|
156
|
+
// served beside the grade) becomes `rounds`; a task with none falls back to its
|
|
157
|
+
// final grade alone, exactly as before.
|
|
151
158
|
function toAuditRow({ task_id, task_status, grade }) {
|
|
159
|
+
const attempts = grade && Array.isArray(grade.attempts) ? grade.attempts : [];
|
|
160
|
+
const rounds = attempts.map((a) => (a && Array.isArray(a.workers) ? a.workers : null)).filter((w) => w && w.length);
|
|
152
161
|
const row = trimRecordedGrade({ task_id, grade });
|
|
153
|
-
|
|
162
|
+
if (!row && rounds.length === 0) return null;
|
|
163
|
+
return {
|
|
164
|
+
task_id: String(task_id),
|
|
165
|
+
task_status: task_status || null,
|
|
166
|
+
passed: !!(grade && grade.passed === true),
|
|
167
|
+
workers: row ? row.workers : rounds[rounds.length - 1],
|
|
168
|
+
...(rounds.length ? { rounds } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The panels one dataset row contributes: every recorded round when there are
|
|
173
|
+
// any (the LAST one is the final grade, the one the owner's decision is about),
|
|
174
|
+
// else the final grade alone.
|
|
175
|
+
function panelsOf(g) {
|
|
176
|
+
const sets = Array.isArray(g.rounds) && g.rounds.length ? g.rounds : [g.workers];
|
|
177
|
+
return sets
|
|
178
|
+
.map((workers, i) => ({ task_id: String(g.task_id), final: i === sets.length - 1, votes: panelVotes(workers) }))
|
|
179
|
+
.filter((p) => Object.keys(p.votes).length > 0);
|
|
154
180
|
}
|
|
155
181
|
|
|
156
182
|
// The whole report, pure over a dataset { grades: [toAuditRow…], overrides: [{task_id, status}] }.
|
|
157
183
|
function audit(dataset, { minN = MIN_N } = {}) {
|
|
158
184
|
const floor = Math.max(MIN_N, Number(minN) || MIN_N);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
// Agreement between workers reads EVERY round; the owner's decision is about
|
|
186
|
+
// the work as it landed, so the owner-facing statistics read final rounds only.
|
|
187
|
+
const panels = (dataset.grades || []).flatMap(panelsOf);
|
|
188
|
+
const finals = panels.filter((p) => p.final);
|
|
162
189
|
const labels = ownerLabels(dataset);
|
|
163
190
|
// 1 when the worker's vote disagrees with the owner (objected to accepted
|
|
164
191
|
// work, or cleared rejected work); only on labelled tasks it voted on.
|
|
165
|
-
const errorOf = (p, kind) => (labels.has(p.task_id) && kind in p.votes
|
|
192
|
+
const errorOf = (p, kind) => (!p.final ? null : (labels.has(p.task_id) && kind in p.votes
|
|
166
193
|
? Number(p.votes[kind] !== (labels.get(p.task_id).label === 'reject' ? 1 : 0))
|
|
167
|
-
: null);
|
|
194
|
+
: null));
|
|
168
195
|
|
|
169
196
|
const pairwise = [];
|
|
170
197
|
for (let i = 0; i < PANEL.length; i++) {
|
|
@@ -186,7 +213,7 @@ function audit(dataset, { minN = MIN_N } = {}) {
|
|
|
186
213
|
const byLabel = { accept: 0, reject: 0 };
|
|
187
214
|
for (const v of labels.values()) byLabel[v.label]++;
|
|
188
215
|
const perWorker = PANEL.map((kind) => {
|
|
189
|
-
const pairs =
|
|
216
|
+
const pairs = finals
|
|
190
217
|
.filter((p) => labels.has(p.task_id) && kind in p.votes)
|
|
191
218
|
.map((p) => [p.votes[kind], labels.get(p.task_id).label === 'reject' ? 1 : 0]);
|
|
192
219
|
return { kind, ...guardedStats(pairs, floor) };
|
|
@@ -195,6 +222,7 @@ function audit(dataset, { minN = MIN_N } = {}) {
|
|
|
195
222
|
return {
|
|
196
223
|
min_n: floor,
|
|
197
224
|
panels: panels.length,
|
|
225
|
+
tasks: finals.length,
|
|
198
226
|
pairwise,
|
|
199
227
|
mean_pairwise_phi: meanPhi,
|
|
200
228
|
effective_votes: meanPhi == null ? null : effectiveVotes(PANEL.length, meanPhi),
|
|
@@ -223,7 +251,7 @@ const statLine = (s) => (s.refused ? `REFUSED — ${s.refused}` : `n=${s.n} agr
|
|
|
223
251
|
|
|
224
252
|
function printReport(r) {
|
|
225
253
|
const out = [];
|
|
226
|
-
out.push(`grade correlation audit — ${r.panels} recorded panel
|
|
254
|
+
out.push(`grade correlation audit — ${r.panels} recorded panel round(s) over ${r.tasks} task(s), minimum n = ${r.min_n}`);
|
|
227
255
|
out.push('A vote is OBJECTS (concerns/fail) or CLEARS (pass); an errored worker with no finding casts none.');
|
|
228
256
|
out.push('');
|
|
229
257
|
out.push('(a) Do the workers fail differently? — agreement between each pair on the same task');
|
|
@@ -247,8 +275,9 @@ function printReport(r) {
|
|
|
247
275
|
out.push('');
|
|
248
276
|
out.push('Caveats: labels exist only where a person decided — override requests and manual confirms of work the');
|
|
249
277
|
out.push('panel did not pass — so they are a selected set, not a sample. A grade that auto-landed on a pass is');
|
|
250
|
-
out.push('never a label (that is the grader agreeing with itself).
|
|
251
|
-
out.push('verdicts (grade_attempts
|
|
278
|
+
out.push('never a label (that is the grader agreeing with itself). Agreement (a) reads every round that kept its');
|
|
279
|
+
out.push('per-worker verdicts (grade_attempts.worker_verdicts); rounds graded before that column existed are lost,');
|
|
280
|
+
out.push('and such a task counts once, as its final grade. Owner comparisons (b) and error correlation read final rounds only.');
|
|
252
281
|
console.log(out.join('\n'));
|
|
253
282
|
}
|
|
254
283
|
|
|
@@ -271,6 +300,7 @@ module.exports = {
|
|
|
271
300
|
effectiveVotes,
|
|
272
301
|
ownerLabels,
|
|
273
302
|
panelVotes,
|
|
303
|
+
panelsOf,
|
|
274
304
|
toAuditRow,
|
|
275
305
|
voteOf,
|
|
276
306
|
};
|
|
@@ -160,7 +160,7 @@ async function fetchRecentGrades({ recent = 150, statuses = ['shipped', 'complet
|
|
|
160
160
|
const out = new Array(picked.length).fill(null);
|
|
161
161
|
for (let i = 0; i < picked.length; i += FETCH_CONCURRENCY) {
|
|
162
162
|
await Promise.all(picked.slice(i, i + FETCH_CONCURRENCY).map(async (t, j) => {
|
|
163
|
-
const r = await api.tasks.getTasksId({ id: t.id, query: { include: 'grade' } });
|
|
163
|
+
const r = await api.tasks.getTasksId({ id: t.id, query: { include: 'grade,grade_attempts' } });
|
|
164
164
|
if (r.ok && r.data && r.data.grade) out[i + j] = { task_id: t.id, task_status: t.status, at: t.at, grade: r.data.grade };
|
|
165
165
|
}));
|
|
166
166
|
}
|
|
@@ -35,6 +35,7 @@ const path = require('path');
|
|
|
35
35
|
const { spawnSync } = require('child_process');
|
|
36
36
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
37
37
|
const { appUser, dbName, instanceUser, parseTargetRef, resolveOwnerGithubToken, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
38
|
+
const { ensurePinWriteKey, hasOwnAccount, pinKeyPath, pinSshCommand } = require('./provision-pin-key.js');
|
|
38
39
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
39
40
|
const { RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, parseChangeRecords, notesBetween } = require('./release-notes.js');
|
|
40
41
|
|
|
@@ -329,16 +330,20 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
329
330
|
// on top of them would change what the live box runs with no restart and no health check.
|
|
330
331
|
// That is reported for an operator to reconcile, never resolved here.
|
|
331
332
|
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
333
|
+
// WHAT MAKES IT DURABLE (task 1004291, ADR 0350). The owner's token is kept for an hour
|
|
334
|
+
// after they sign in with repository access (GITHUB_TOKEN_TTL_MS in src/bongos/db.js — one
|
|
335
|
+
// wizard sitting), so on its own it cannot carry an upgrade days later. The hub therefore
|
|
336
|
+
// holds a per-project WRITE deploy key (provision-pin-key.js) and tries it FIRST: write key
|
|
337
|
+
// → owner token (when fresh) → the box's own remote, which can only read a private repo.
|
|
338
|
+
// A fresh token also mints the key when the project has none, so reconnecting GitHub once
|
|
339
|
+
// is enough for every later upgrade. The note says which of these is missing, in words.
|
|
336
340
|
|
|
337
341
|
const REPO_NOTE_PREFIX = 'repo_not_updated';
|
|
338
342
|
const REPO_NOTES = Object.freeze({
|
|
339
343
|
no_repo: 'This project has no GitHub repository on record, so the new version was saved on the server only.',
|
|
340
344
|
no_branch: 'The server\'s copy of the project is not on a branch, so there was nothing to save to GitHub.',
|
|
341
|
-
no_access: 'The hub had no permission to write to the project\'s GitHub repository: the owner\'s GitHub access is only kept for an hour after they connect GitHub, and the server\'s own key can only read the repository.',
|
|
345
|
+
no_access: 'The hub had no permission to write to the project\'s GitHub repository: it holds no write key for it yet, the owner\'s GitHub access is only kept for an hour after they connect GitHub, and the server\'s own key can only read the repository. Reconnecting GitHub with repository access sets up lasting access.',
|
|
346
|
+
needs_account: 'The hub could not set up lasting write access to the project\'s GitHub repository: this project needs its own account on the server first. Once it has one, reconnecting GitHub with repository access sets up lasting access.',
|
|
342
347
|
refused: 'GitHub turned down the owner\'s GitHub access — it may have expired, or lack permission to write to the repository.',
|
|
343
348
|
behind: 'GitHub has changes the server does not have, so the new version could not be added on top of them. An operator needs to bring the two back in line; the site itself is running normally.',
|
|
344
349
|
failed: 'The hub could not save the new version to GitHub.',
|
|
@@ -399,6 +404,9 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
399
404
|
};
|
|
400
405
|
const repo = parseTargetRef(inst.target_ref);
|
|
401
406
|
if (!repo) return miss('no_repo');
|
|
407
|
+
// Exported and callable on its own, and the slug reaches shell strings below (the write
|
|
408
|
+
// key's path), so it is checked here rather than trusting coreUpgradeInstance's own guard.
|
|
409
|
+
if (!isValidSlug(inst.slug)) return miss('failed', '(the project name is not a valid slug)');
|
|
402
410
|
const head = exec(`git -C ${instanceDir} symbolic-ref --short HEAD`, { allowFail: true });
|
|
403
411
|
const branch = failedRun(head) ? '' : String(head.stdout || '').trim();
|
|
404
412
|
if (!BRANCH_RE.test(branch)) return miss('no_branch');
|
|
@@ -407,6 +415,26 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
407
415
|
const env = { GIT_TERMINAL_PROMPT: '0' }; // a refused credential fails; it never waits at a prompt
|
|
408
416
|
|
|
409
417
|
const token = await resolveOwnerGithubToken(inst, deps, { tag: '[core-upgrade]' });
|
|
418
|
+
|
|
419
|
+
// 1. The hub's own WRITE key (ADR 0350). A fresh token mints it when the project has
|
|
420
|
+
// none yet — the reconnect the deploy page asks for — and the mint is best effort: a
|
|
421
|
+
// miss falls through to the token, then to the box's remote, exactly as before.
|
|
422
|
+
const fsImpl = deps.fsImpl || fs;
|
|
423
|
+
const keyPath = pinKeyPath(inst, deps);
|
|
424
|
+
if (token && !fsImpl.existsSync(keyPath)) await ensurePinWriteKey(inst, deps, { token });
|
|
425
|
+
if (fsImpl.existsSync(keyPath)) {
|
|
426
|
+
const r = exec(`${hardened} -c ${pinSshCommand(keyPath)} push git@github.com:${repo.owner}/${repo.name}.git HEAD:${branch}`,
|
|
427
|
+
{ allowFail: true, env });
|
|
428
|
+
if (!failedRun(r)) {
|
|
429
|
+
log(` ✓ pushed the pin to ${at} from the hub, over its write deploy key`);
|
|
430
|
+
return { pushed: true, via: 'write-key', branch };
|
|
431
|
+
}
|
|
432
|
+
const fault = pushFault(r);
|
|
433
|
+
// The key WORKED and the history stopped it: no other credential changes that.
|
|
434
|
+
if (fault === 'behind') return miss('behind', gitLine(r));
|
|
435
|
+
log(` • ${inst.slug}: the write key did not push (${fault}) — trying the next credential`);
|
|
436
|
+
}
|
|
437
|
+
|
|
410
438
|
if (token) {
|
|
411
439
|
const credFile = path.join(os.tmpdir(), `bongos-gitcred-upgrade-${inst.slug}-${process.pid}-${crypto.randomBytes(6).toString('hex')}`);
|
|
412
440
|
let r = null;
|
|
@@ -436,7 +464,10 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
436
464
|
const fault = pushFault(r);
|
|
437
465
|
// A refusal here is not the owner's access being turned down — there was none to turn
|
|
438
466
|
// down. It is the read-only key, which is the no-access case.
|
|
439
|
-
|
|
467
|
+
// ...unless the project has no account of its own, in which case no key CAN be minted
|
|
468
|
+
// for it (ADR 0350) and reconnecting GitHub alone will not help: say that instead.
|
|
469
|
+
if (fault === 'refused') return miss(hasOwnAccount(inst, exec) ? 'no_access' : 'needs_account', gitLine(r));
|
|
470
|
+
return miss(fault, gitLine(r));
|
|
440
471
|
}
|
|
441
472
|
|
|
442
473
|
// The pre-flight lines a dry run emits, and the refusal when it has one. upgrade.js writes
|
|
@@ -990,4 +1021,4 @@ async function snapshotAllCoreVersions(deps) {
|
|
|
990
1021
|
return { seen };
|
|
991
1022
|
}
|
|
992
1023
|
|
|
993
|
-
module.exports = { UPGRADE_SHAPES, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
1024
|
+
module.exports = { pushUpgradePin, UPGRADE_SHAPES, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-pin-key.js — the hub's per-project WRITE deploy key (task 1004291, ADR 0350,
|
|
3
|
+
// amending ADR 0176).
|
|
4
|
+
//
|
|
5
|
+
// WHY. A hosted project's upgrade commits a new core pin in the project's checkout, and
|
|
6
|
+
// the hub pushes it to the owner's GitHub repo (task 1004065, pushUpgradePin). The owner's
|
|
7
|
+
// token that would do that is stored for one hour only, and the checkout's own `origin`
|
|
8
|
+
// is a READ-ONLY deploy key (ADR 0176) — so most pushes ended "GitHub was not updated" and
|
|
9
|
+
// the box and the repo drifted. A second deploy key, registered WRITABLE and held by the
|
|
10
|
+
// runner alone, is the lasting credential.
|
|
11
|
+
//
|
|
12
|
+
// WHERE IT LIVES AND WHO CAN READ IT. Outside every project checkout, in a directory of
|
|
13
|
+
// the RUNNER's own account (mode 0700, key 0600). The project's own unix account
|
|
14
|
+
// (bongos-<slug>, ADR 0281) can read neither. A project still running as the shared app
|
|
15
|
+
// user has the SAME uid as the runner and could read it, so it is REFUSED a key
|
|
16
|
+
// ("needs its own account first") rather than handed one.
|
|
17
|
+
//
|
|
18
|
+
// NEVER THROWS. A failure to mint is a missed convenience, not a broken standup: every
|
|
19
|
+
// path returns `{ skipped }` and logs. The read-only pull key (deployKeyPath in
|
|
20
|
+
// provision-repo.js) is untouched by anything here.
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const { REPO_ROOT } = require('./provision-config.js');
|
|
26
|
+
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
27
|
+
const { instanceUser, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo.js');
|
|
28
|
+
|
|
29
|
+
const NEEDS_OWN_ACCOUNT = 'needs its own account first';
|
|
30
|
+
|
|
31
|
+
// Both names below reach a shell (ssh-keygen -C/-f, rm -f), so the slug is checked HERE, at
|
|
32
|
+
// the point of use, rather than trusting that some caller validated it first.
|
|
33
|
+
function safeSlug(inst) {
|
|
34
|
+
const slug = inst && inst.slug;
|
|
35
|
+
if (!isValidSlug(slug)) throw new Error(`refusing to build a write-key path from an invalid slug ${JSON.stringify(slug)}`);
|
|
36
|
+
return slug;
|
|
37
|
+
}
|
|
38
|
+
function pinKeyTitle(inst) { return `cloudbongos-pin-${safeSlug(inst)}`; }
|
|
39
|
+
|
|
40
|
+
// The runner-only directory. PROVISION_PIN_KEY_DIR pins it; otherwise it sits under the
|
|
41
|
+
// runner account's own home. Never under standaloneBase, so it can never ride along with
|
|
42
|
+
// (or be reached through) a project checkout.
|
|
43
|
+
function pinKeyDir(deps = {}) {
|
|
44
|
+
const pinned = deps.pinKeyDir || process.env.PROVISION_PIN_KEY_DIR;
|
|
45
|
+
if (pinned) return String(pinned).replace(/\\/g, '/');
|
|
46
|
+
return path.posix.join(os.homedir().replace(/\\/g, '/'), '.config', 'cloudbongos', 'pin-keys');
|
|
47
|
+
}
|
|
48
|
+
function pinKeyPath(inst, deps = {}) { return path.posix.join(pinKeyDir(deps), `${safeSlug(inst)}.key`); }
|
|
49
|
+
|
|
50
|
+
const failed = (r) => !r || r.softFailed === true || r.ok === false;
|
|
51
|
+
|
|
52
|
+
// Does this project have a unix account of its own? Fail CLOSED: only a positive answer
|
|
53
|
+
// counts, because the wrong guess hands a tenant process the key that pushes to the
|
|
54
|
+
// owner's repo.
|
|
55
|
+
function hasOwnAccount(inst, exec) {
|
|
56
|
+
let account;
|
|
57
|
+
try { account = instanceUser(inst); } catch { return false; }
|
|
58
|
+
const r = exec(`id -u ${account}`, { allowFail: true });
|
|
59
|
+
return !failed(r) && /^\d+$/.test(String((r && r.stdout) || '').trim());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Create the directory (0700) and mint the key (0600), then PROVE the modes: a key that
|
|
63
|
+
// GitHub trusts to write must never be group- or world-readable.
|
|
64
|
+
function mintLocal(inst, exec, deps) {
|
|
65
|
+
const dir = pinKeyDir(deps), keyPath = pinKeyPath(inst, deps), title = pinKeyTitle(inst);
|
|
66
|
+
exec(`(umask 077 && mkdir -p ${dir}) && chmod 700 ${dir}`);
|
|
67
|
+
exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
|
|
68
|
+
exec(`(umask 077 && ssh-keygen -q -t ed25519 -N '' -C ${title} -f ${keyPath})`);
|
|
69
|
+
exec(`chmod 600 ${keyPath}`);
|
|
70
|
+
const modes = exec(`stat -c '%a' ${dir} ${keyPath}`, { allowFail: true });
|
|
71
|
+
const got = String((modes && modes.stdout) || '').trim().split(/\s+/);
|
|
72
|
+
if (failed(modes) || got[0] !== '700' || got[1] !== '600') {
|
|
73
|
+
exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
|
|
74
|
+
throw new Error(`the write key's permissions could not be confirmed as 700/600 (saw ${got.join('/') || 'nothing'})`);
|
|
75
|
+
}
|
|
76
|
+
return keyPath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const keyBody = (k) => String(k || '').trim().split(/\s+/).slice(0, 2).join(' ');
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Make sure a per-project WRITE deploy key exists locally and is registered on the repo
|
|
83
|
+
* with `read_only: false`. `opts.token` is an owner token already in hand. Idempotent
|
|
84
|
+
* (by title; a stale or read-only same-title registration is replaced). NEVER THROWS.
|
|
85
|
+
* Returns `{ minted, keyPath }` or `{ skipped: <why>, note }`.
|
|
86
|
+
*/
|
|
87
|
+
async function ensurePinWriteKey(inst, deps, opts = {}) {
|
|
88
|
+
const log = deps.log || (() => {});
|
|
89
|
+
const exec = deps.controlExec || deps.exec;
|
|
90
|
+
if (!inst || inst.hosting_shape !== 'cloud-host') return { skipped: 'shape' };
|
|
91
|
+
if (!isValidSlug(inst.slug)) return { skipped: 'invalid_slug' };
|
|
92
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
93
|
+
if (!repo) return { skipped: 'no_repo' };
|
|
94
|
+
if (deps.apply === false) {
|
|
95
|
+
log(` [pin-key] would mint a writable deploy key "${pinKeyTitle(inst)}" for ${repo.owner}/${repo.name}, held by the runner outside the checkout (ADR 0350)`);
|
|
96
|
+
return { planned: true };
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
if (!hasOwnAccount(inst, exec)) {
|
|
100
|
+
log(` [pin-key] ${inst.slug} ${NEEDS_OWN_ACCOUNT} — no write key minted (it runs as the runner's own user and could read it; ADR 0350)`);
|
|
101
|
+
return { skipped: 'needs_own_account', note: `This project ${NEEDS_OWN_ACCOUNT}.` };
|
|
102
|
+
}
|
|
103
|
+
const token = opts.token || await resolveOwnerGithubToken(inst, deps, { tag: '[pin-key]' });
|
|
104
|
+
if (!token) return { skipped: 'no_token' };
|
|
105
|
+
const gh = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos')); // eslint-disable-line global-require
|
|
106
|
+
const info = await gh.getUserRepo(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
|
|
107
|
+
if (info && info.admin === false) {
|
|
108
|
+
log(` [pin-key] the owner's credential is not admin on ${repo.owner}/${repo.name}; a deploy key needs admin — no write key minted`);
|
|
109
|
+
return { skipped: 'not_admin' };
|
|
110
|
+
}
|
|
111
|
+
const fsImpl = deps.fsImpl || fs;
|
|
112
|
+
const title = pinKeyTitle(inst), keyPath = pinKeyPath(inst, deps);
|
|
113
|
+
const target = { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl };
|
|
114
|
+
if (!fsImpl.existsSync(keyPath) || !fsImpl.existsSync(`${keyPath}.pub`)) mintLocal(inst, exec, deps);
|
|
115
|
+
let pub = String(fsImpl.readFileSync(`${keyPath}.pub`, 'utf8')).trim();
|
|
116
|
+
const ours = (await gh.listDeployKeys(token, target)).find((k) => k.title === title);
|
|
117
|
+
if (ours && keyBody(ours.key) === keyBody(pub) && ours.read_only === false) {
|
|
118
|
+
return { minted: false, keyPath };
|
|
119
|
+
}
|
|
120
|
+
if (ours) await gh.deleteDeployKey(token, { ...target, keyId: ours.id });
|
|
121
|
+
const add = () => gh.addDeployKey(token, { ...target, title, key: pub, readOnly: false });
|
|
122
|
+
let added;
|
|
123
|
+
try { added = await add(); } catch (e) {
|
|
124
|
+
if (!e || e.code !== 'key_in_use') throw e;
|
|
125
|
+
mintLocal(inst, exec, deps);
|
|
126
|
+
pub = String(fsImpl.readFileSync(`${keyPath}.pub`, 'utf8')).trim();
|
|
127
|
+
added = await add();
|
|
128
|
+
}
|
|
129
|
+
if (added && added.read_only === true) {
|
|
130
|
+
// GitHub registered it read-only anyway: it would never write, and a key that looks
|
|
131
|
+
// like a write key but is not is worse than none.
|
|
132
|
+
await gh.deleteDeployKey(token, { ...target, keyId: added.id }).catch(() => {});
|
|
133
|
+
return { skipped: 'registered_read_only' };
|
|
134
|
+
}
|
|
135
|
+
log(` [pin-key] registered writable deploy key "${title}" on ${repo.owner}/${repo.name}`);
|
|
136
|
+
return { minted: true, keyPath };
|
|
137
|
+
} catch (e) {
|
|
138
|
+
log(` [pin-key] write key not set up for ${inst.slug}: ${(e && e.message) || e}`);
|
|
139
|
+
return { skipped: 'failed', note: String((e && e.message) || e).slice(0, 160) };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The `-c` that points ONE git push at the write key. IdentitiesOnly + -F /dev/null so no
|
|
144
|
+
// agent or ~/.ssh/config identity can answer instead. PURE.
|
|
145
|
+
function pinSshCommand(keyPath) {
|
|
146
|
+
return `core.sshCommand="ssh -F /dev/null -i ${keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Remove the write key: the registration (when a token is at hand) and the local files.
|
|
151
|
+
* Best effort, never throws. `keys` is an already-fetched deploy-key list, to save a call.
|
|
152
|
+
*/
|
|
153
|
+
async function revokePinWriteKey(inst, deps, { token = null, repo = null, keys = null } = {}) {
|
|
154
|
+
const log = deps.log || (() => {});
|
|
155
|
+
const exec = deps.controlExec || deps.exec;
|
|
156
|
+
const title = pinKeyTitle(inst), keyPath = pinKeyPath(inst, deps);
|
|
157
|
+
if (token && repo) {
|
|
158
|
+
try {
|
|
159
|
+
const gh = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos')); // eslint-disable-line global-require
|
|
160
|
+
const list = keys || await gh.listDeployKeys(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
|
|
161
|
+
const ours = list.find((k) => k.title === title);
|
|
162
|
+
if (ours) {
|
|
163
|
+
if (deps.apply === false) log(` would revoke the "${title}" deploy key on ${repo.owner}/${repo.name} if present`);
|
|
164
|
+
else {
|
|
165
|
+
await gh.deleteDeployKey(token, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
|
|
166
|
+
log(` revoked the "${title}" write deploy key on ${repo.owner}/${repo.name}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (e) { log(` (write-key revoke skipped: ${(e && e.message) || e})`); }
|
|
170
|
+
} else if (repo) {
|
|
171
|
+
log(` (write-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${title}" deploy key, remove it in the repo's settings)`);
|
|
172
|
+
}
|
|
173
|
+
if (deps.apply !== false) exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { NEEDS_OWN_ACCOUNT, hasOwnAccount, pinKeyTitle, pinKeyDir, pinKeyPath, ensurePinWriteKey, revokePinWriteKey, pinSshCommand };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-render.js — the Render standup leg: the runner half of the `render-standup`
|
|
3
|
+
// intent (task 1004183; ADR 0327, ADR 0345). What it stands up is the project's APP, from
|
|
4
|
+
// the project's own repository — never the Bongos hall, which we host (task 1004350). Its own file because provision.js sits at
|
|
5
|
+
// the size ratchet; the dispatch there requires it inline.
|
|
6
|
+
//
|
|
7
|
+
// WHAT IT DOES. Stands a project up on its OWNER's Render account, with a key the owner
|
|
8
|
+
// lent for this one run:
|
|
9
|
+
// - ADOPT links a Render service the owner already runs for this repository. It reads
|
|
10
|
+
// that service through the workspace guard, checks it builds this project's
|
|
11
|
+
// repository, and records it. It makes NO write call to their account.
|
|
12
|
+
// - GREENFIELD creates the Postgres when the owner asked for one, then the web service
|
|
13
|
+
// (render-payload.js decides what they are). Render deploys a new service on its own.
|
|
14
|
+
// Ids are written to the row as each resource is created, so an owner who re-enters their
|
|
15
|
+
// key after a failure resumes onto what already exists instead of creating a second one.
|
|
16
|
+
//
|
|
17
|
+
// THE KEY IS BORROWED, NEVER STORED (ADR 0327 §2). It reaches this leg encrypted on the
|
|
18
|
+
// intent (provisioning_intents.borrowed_key_enc). It is decrypted here, used for this run
|
|
19
|
+
// only, and the column is cleared in a `finally` — on success, on a refusal and on a
|
|
20
|
+
// throw. resolveIntent clears it again, and the table's CHECK refuses a resolved intent
|
|
21
|
+
// that still holds one. What the encryption buys, said plainly: it protects the key from
|
|
22
|
+
// a database dump or a nightly backup while the intent waits. It does NOT defend against
|
|
23
|
+
// a compromised web tier — the key arrives there in plaintext before anything can
|
|
24
|
+
// encrypt it.
|
|
25
|
+
//
|
|
26
|
+
// A FAILURE IS TERMINAL. Every failure returns `terminal: true`, so the runner retires the
|
|
27
|
+
// intent instead of re-queuing it. There is no key left to retry with, and an unattended
|
|
28
|
+
// retry holding someone else's cloud credential is not a thing this platform does: the
|
|
29
|
+
// owner retries by entering their key again.
|
|
30
|
+
//
|
|
31
|
+
// Every Render call goes through scripts/gds/render-api.js, which pins the workspace and
|
|
32
|
+
// asserts it on every response, and which cannot delete.
|
|
33
|
+
|
|
34
|
+
const { RenderApi } = require('./render-api.js');
|
|
35
|
+
const payload = require('./render-payload.js');
|
|
36
|
+
const { parseTargetRef } = require('./provision-repo-auth.js');
|
|
37
|
+
|
|
38
|
+
// A key waiting longer than this was lent for a run that never came. It is refused and
|
|
39
|
+
// cleared rather than spent late.
|
|
40
|
+
const KEY_TTL_MS = 30 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
function renderApiFor(deps, token, ownerId) {
|
|
43
|
+
if (deps.renderApiFactory) return deps.renderApiFactory({ token, ownerId });
|
|
44
|
+
return new RenderApi({ token, ownerId, dryRun: !deps.apply, fetchImpl: deps.fetchImpl, log: deps.log });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Merge `patch` into the row's app_host. The runner is the only writer while an intent
|
|
48
|
+
// runs, and the web tier only writes it when enqueuing, which the open-intent index
|
|
49
|
+
// serialises — so a merge needs no read-modify-write.
|
|
50
|
+
async function recordAppHost(db, instId, patch) {
|
|
51
|
+
await db.query(
|
|
52
|
+
`UPDATE provisioning_instances SET app_host = COALESCE(app_host, '{}'::jsonb) || $2::jsonb WHERE id = $1`,
|
|
53
|
+
[instId, JSON.stringify({ ...patch, updatedAt: new Date().toISOString() })]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function clearBorrowedKey(db, intentId) {
|
|
57
|
+
await db.query(`UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = $1`, [intentId]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Whether a Render service's repository is this project's. Render reports it as a URL;
|
|
61
|
+
// compared as owner/name, case-insensitively, the way GitHub resolves them.
|
|
62
|
+
function sameRepo(renderRepo, targetRef) {
|
|
63
|
+
const a = parseTargetRef(renderRepo), b = parseTargetRef(targetRef);
|
|
64
|
+
return !!(a && b && a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function adopt(api, inst, host, { db, log }) {
|
|
68
|
+
if (!host.serviceId) throw new Error('adopting needs the id of the Render service to link');
|
|
69
|
+
const svc = await api.getService(host.serviceId);
|
|
70
|
+
// Linking someone's other service to this project would show its URL as this app's.
|
|
71
|
+
if (!sameRepo(svc.repo, inst.target_ref)) {
|
|
72
|
+
throw new Error(`Render service ${svc.id} builds ${svc.repo || 'no repository'}, not ${inst.target_ref}`);
|
|
73
|
+
}
|
|
74
|
+
const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
|
|
75
|
+
await recordAppHost(db, inst.id, { state: 'linked', serviceUrl: url, error: null });
|
|
76
|
+
log(` [render] linked ${inst.slug} to service ${svc.id}${url ? ` (${url})` : ''} — nothing created or changed on Render`);
|
|
77
|
+
return { ok: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function greenfield(api, inst, host, deps) {
|
|
81
|
+
const { db, log } = deps;
|
|
82
|
+
const plans = payload.plansFor(host);
|
|
83
|
+
const build = payload.buildFor(host);
|
|
84
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
85
|
+
if (!repo) throw new Error('the project has no GitHub repository for Render to build from');
|
|
86
|
+
|
|
87
|
+
// 1. The database, only when the owner asked for one — reused if a previous run got
|
|
88
|
+
// this far.
|
|
89
|
+
let dbUrl = null;
|
|
90
|
+
if (host.database === true) {
|
|
91
|
+
let pg = host.postgresId ? await api.getPostgres(host.postgresId) : null;
|
|
92
|
+
if (!pg) {
|
|
93
|
+
pg = await api.createPostgres(payload.renderPostgresPayload(inst, { ownerId: api.ownerId, dbPlan: plans.dbPlan, region: plans.region }));
|
|
94
|
+
await recordAppHost(db, inst.id, { postgresId: pg.id, dbPlan: plans.dbPlan, state: 'creating' });
|
|
95
|
+
log(` [render] created database ${pg.id} (${plans.dbPlan})`);
|
|
96
|
+
}
|
|
97
|
+
await api.settlePostgres(pg.id, { sleepFn: deps.sleepFn });
|
|
98
|
+
const conn = await api.postgresConnectionInfo(pg.id);
|
|
99
|
+
dbUrl = conn && conn.internalConnectionString;
|
|
100
|
+
if (!dbUrl) throw new Error(`Render gave database ${pg.id} no internal connection string`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. The app's web service — reused likewise. Render starts its first deploy itself.
|
|
104
|
+
let svc = host.serviceId ? await api.getService(host.serviceId) : null;
|
|
105
|
+
if (!svc) {
|
|
106
|
+
const created = await api.createService(payload.renderServicePayload(inst, {
|
|
107
|
+
ownerId: api.ownerId, repoUrl: `https://github.com/${repo.owner}/${repo.name}`, branch: host.branch,
|
|
108
|
+
webPlan: plans.webPlan, region: plans.region, build, dbUrl,
|
|
109
|
+
}));
|
|
110
|
+
svc = created.service;
|
|
111
|
+
await recordAppHost(db, inst.id, { serviceId: svc.id, webPlan: plans.webPlan, region: plans.region, state: 'creating' });
|
|
112
|
+
log(` [render] created web service ${svc.id} (${plans.webPlan}, ${build.runtime})`);
|
|
113
|
+
}
|
|
114
|
+
const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
|
|
115
|
+
await recordAppHost(db, inst.id, { state: 'deploying', serviceUrl: url, error: null });
|
|
116
|
+
log(` [render] ${inst.slug} is deploying at ${url}`);
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function renderStandupInstance(inst, deps, intent) {
|
|
121
|
+
const { db, log } = deps;
|
|
122
|
+
const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
|
|
123
|
+
const host = inst.app_host || {};
|
|
124
|
+
try {
|
|
125
|
+
if (!intent || !intent.borrowed_key_enc) return refuse('no Render key came with this request — enter it again to retry');
|
|
126
|
+
if (host.platform !== 'render') return refuse(`this project's app is not set to run on Render (platform '${host.platform || 'none'}')`);
|
|
127
|
+
if (!host.ownerId) return refuse('no Render workspace was chosen');
|
|
128
|
+
const age = Date.now() - new Date(intent.created_at || 0).getTime();
|
|
129
|
+
if (!(age < KEY_TTL_MS)) return refuse('the Render key waited too long to be used and was cleared — enter it again to retry');
|
|
130
|
+
if (host.mode !== 'adopt' && host.mode !== 'greenfield') return refuse(`unknown Render setup mode '${host.mode}'`);
|
|
131
|
+
|
|
132
|
+
const api = renderApiFor(deps, secretBox.decrypt(intent.borrowed_key_enc), host.ownerId);
|
|
133
|
+
// Membership, never selection: the workspace is the one the owner named, and it must
|
|
134
|
+
// be one this key can act in.
|
|
135
|
+
const owners = await api.listOwners();
|
|
136
|
+
if (!owners.some((o) => o.id === host.ownerId)) return refuse(`this Render key cannot reach workspace ${host.ownerId}`);
|
|
137
|
+
|
|
138
|
+
return host.mode === 'adopt' ? await adopt(api, inst, host, deps) : await greenfield(api, inst, host, deps);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return refuse(e && e.message ? e.message : String(e));
|
|
141
|
+
} finally {
|
|
142
|
+
if (intent && intent.id != null) await clearBorrowedKey(db, intent.id);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function refuse(error) {
|
|
146
|
+
await recordAppHost(db, inst.id, { state: 'failed', error }).catch(() => {});
|
|
147
|
+
log(` ✖ [render] ${inst.slug}: ${error}`);
|
|
148
|
+
return { ok: false, terminal: true, error };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The wizard's path (task 1004352): Create queues the project's `provision` run, and the
|
|
153
|
+
// key the owner lent arrives on THAT run a moment later (render-standup.js
|
|
154
|
+
// attachToProvision). So after the project is set up, this runs the Render leg in the same
|
|
155
|
+
// drain. The key is re-read from the row, because it may have been attached after this
|
|
156
|
+
// run was claimed. Whatever happens the key is gone when this returns:
|
|
157
|
+
// - project up → the Render leg spends and clears it. Its failure is the APP's, recorded
|
|
158
|
+
// on app_host; the project itself is still up, so the provision run stays a success;
|
|
159
|
+
// - project not up → the key is cleared now and the app marked failed, so a provision
|
|
160
|
+
// retry never carries a key into a later run. The owner connects Render again once the
|
|
161
|
+
// project is up.
|
|
162
|
+
async function afterProvision(inst, deps, intent, result) {
|
|
163
|
+
const { db } = deps;
|
|
164
|
+
const { rows } = await db.query(`SELECT borrowed_key_enc FROM provisioning_intents WHERE id = $1`, [intent.id]);
|
|
165
|
+
const keyEnc = rows[0] && rows[0].borrowed_key_enc;
|
|
166
|
+
if (!keyEnc) return result;
|
|
167
|
+
if (!result || result.ok === false) {
|
|
168
|
+
await clearBorrowedKey(db, intent.id);
|
|
169
|
+
await recordAppHost(db, inst.id, { state: 'failed', error: 'the project could not be set up, so its app was not set up on Render — connect Render again once the project is live' }).catch(() => {});
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
const fresh = (await deps.provisioning.getInstanceById(db, inst.id)) || inst;
|
|
173
|
+
const app = await renderStandupInstance(fresh, deps, { ...intent, borrowed_key_enc: keyEnc });
|
|
174
|
+
if (app.ok) return result;
|
|
175
|
+
return { ...result, doneNote: `project is up, but its app was not set up on Render: ${app.error}` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Teardown's half (ADR 0327 §3): forget the Render app, never delete it. The ids stay on
|
|
179
|
+
// the row so an operator can still say which services were the project's; the owner
|
|
180
|
+
// deletes them in their own Render dashboard if they want to stop paying.
|
|
181
|
+
async function forgetRenderApp(inst, { db, log, apply }) {
|
|
182
|
+
const host = inst.app_host;
|
|
183
|
+
if (!host || host.platform !== 'render') return;
|
|
184
|
+
const what = [host.serviceId && `service ${host.serviceId}`, host.postgresId && `database ${host.postgresId}`].filter(Boolean).join(' and ') || 'nothing yet';
|
|
185
|
+
log(` [render] ${apply ? 'disconnected' : 'would disconnect'} from Render (${what}); nothing on Render is deleted — the owner removes it in their own dashboard`);
|
|
186
|
+
if (apply) await recordAppHost(db, inst.id, { state: 'disconnected' });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { renderStandupInstance, afterProvision, forgetRenderApp, clearBorrowedKey, recordAppHost, KEY_TTL_MS };
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const { CONFIG, REPO_ROOT } = require('./provision-config.js');
|
|
14
14
|
const { dbName, deployKeyPath, deployKeyTitle, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo.js');
|
|
15
|
+
const { pinKeyPath, pinKeyTitle, revokePinWriteKey } = require('./provision-pin-key.js');
|
|
15
16
|
const { resolveProcessManager } = require('./provision-process.js');
|
|
16
17
|
const { caddySnippetPath, dnsReleaseEnv } = require('./provision-net.js');
|
|
17
18
|
const apexMod = require('./instance-apex.js');
|
|
@@ -42,6 +43,8 @@ async function teardownInstance(inst, deps) {
|
|
|
42
43
|
await deps.doApi.deleteDroplet(inst.droplet_id);
|
|
43
44
|
}
|
|
44
45
|
parkAddress(inst, { controlExec, log, apply });
|
|
46
|
+
// The app's Render services are the OWNER's: forget them, never delete them (ADR 0327 §3, task 1004183).
|
|
47
|
+
await require('./provision-render.js').forgetRenderApp(inst, { db, log, apply });
|
|
45
48
|
// Revoke the instance's deploy key (ADR 0176) and remove its local halves. The
|
|
46
49
|
// checkout itself is deliberately left in place (data is precious), so the key
|
|
47
50
|
// files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
|
|
@@ -61,12 +64,16 @@ async function teardownInstance(inst, deps) {
|
|
|
61
64
|
await ghRepos.deleteDeployKey(token, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
|
|
62
65
|
log(` revoked the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name}`);
|
|
63
66
|
}
|
|
64
|
-
|
|
67
|
+
// The hub's WRITE key (ADR 0350) — the more dangerous of the two to leave behind.
|
|
68
|
+
await revokePinWriteKey(inst, { ...deps, controlExec: boxExec, apply: true, githubRepos: ghRepos }, { token, repo, keys });
|
|
69
|
+
} else log(` would revoke the "${deployKeyTitle(inst)}" and write deploy keys on ${repo.owner}/${repo.name} if present`);
|
|
65
70
|
} else {
|
|
66
|
-
log(` (deploy-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${deployKeyTitle(inst)}" deploy key, remove it in the repo's settings)`);
|
|
71
|
+
log(` (deploy-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${deployKeyTitle(inst)}" or "${pinKeyTitle(inst)}" deploy key, remove it in the repo's settings)`);
|
|
67
72
|
}
|
|
68
73
|
} catch (e) { log(` (deploy-key revoke skipped: ${e && e.message})`); }
|
|
69
74
|
boxExec(`rm -f ${deployKeyPath(inst)} ${deployKeyPath(inst)}.pub`, { allowFail: true });
|
|
75
|
+
// The write key's local half goes whatever happened above (lookup failed, no token).
|
|
76
|
+
boxExec(`rm -f ${pinKeyPath(inst, deps)} ${pinKeyPath(inst, deps)}.pub`, { allowFail: true });
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
79
|
if (apply) {
|