@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.
Files changed (258) hide show
  1. package/.bongos-core.json +573 -188
  2. package/.claude/skills/planning-session/SKILL.md +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +34 -0
  5. package/clients/bongos-client/index.cjs +34 -0
  6. package/clients/bongos-client/index.d.ts +53 -4
  7. package/clients/bongos-client/index.mjs +34 -0
  8. package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
  9. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  10. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
  11. package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
  12. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  13. package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +2 -0
  14. package/docs/adr/0347-every-store-module-ships-a-how-to.md +135 -0
  15. package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  16. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  17. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  18. package/docs/adr/README.md +4 -0
  19. package/docs/api/openapi.json +995 -40
  20. package/docs/api-reference.md +33 -8
  21. package/docs/architecture.md +6 -2
  22. package/docs/copy-inventory.md +620 -553
  23. package/docs/copy-registry.json +1467 -820
  24. package/docs/file-map.md +2 -0
  25. package/docs/module-api-changelog.md +8 -0
  26. package/docs/modules-contract.md +1 -0
  27. package/docs/page-inventory.json +38 -4
  28. package/docs/page-readings.json +1674 -1566
  29. package/migrations/core_260_goals_working_area.sql +63 -0
  30. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  31. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  32. package/modules/agents/lib/validate.js +43 -0
  33. package/modules/autonomy/gauge.js +38 -2
  34. package/modules/grading/grader-subagent.js +37 -2
  35. package/modules/grading/grader-workers/reader.js +65 -5
  36. package/modules/hall-ui/public/approval-queue.css +33 -7
  37. package/modules/hall-ui/public/approval-queue.js +9 -2
  38. package/modules/hall-ui/public/atlas.html +1 -1
  39. package/modules/hall-ui/public/blockers.html +1 -1
  40. package/modules/hall-ui/public/board-room.html +1 -1
  41. package/modules/hall-ui/public/brand-holes.js +121 -0
  42. package/modules/hall-ui/public/collab.html +1 -1
  43. package/modules/hall-ui/public/collab.js +1 -1
  44. package/modules/hall-ui/public/copy-desk.html +1 -1
  45. package/modules/hall-ui/public/deploy.html +6 -1
  46. package/modules/hall-ui/public/deploy.js +22 -8
  47. package/modules/hall-ui/public/diagrams.html +1 -1
  48. package/modules/hall-ui/public/dom-utils.js +20 -0
  49. package/modules/hall-ui/public/drachmae.html +1 -1
  50. package/modules/hall-ui/public/fleet.html +1 -1
  51. package/modules/hall-ui/public/gate.html +1 -1
  52. package/modules/hall-ui/public/goals.html +1 -1
  53. package/modules/hall-ui/public/government.html +1 -1
  54. package/modules/hall-ui/public/idea.html +1 -1
  55. package/modules/hall-ui/public/idea.js +28 -0
  56. package/modules/hall-ui/public/ideas.html +1 -1
  57. package/modules/hall-ui/public/ideas.js +19 -3
  58. package/modules/hall-ui/public/index.html +5 -1
  59. package/modules/hall-ui/public/modules.html +1 -1
  60. package/modules/hall-ui/public/primer.html +1 -1
  61. package/modules/hall-ui/public/profile-nudge.js +21 -4
  62. package/modules/hall-ui/public/profile.html +1 -1
  63. package/modules/hall-ui/public/profile.js +1 -1
  64. package/modules/hall-ui/public/project-settings.html +1 -1
  65. package/modules/hall-ui/public/ranks.html +1 -1
  66. package/modules/hall-ui/public/roadmap.html +1 -1
  67. package/modules/hall-ui/public/roster.html +1 -1
  68. package/modules/hall-ui/public/sessions.html +1 -1
  69. package/modules/hall-ui/public/settings.html +19 -30
  70. package/modules/hall-ui/public/settings.js +24 -177
  71. package/modules/hall-ui/public/settings.states.json +1 -1
  72. package/modules/hall-ui/public/shell.js +4 -2
  73. package/modules/hall-ui/public/studio.css +14 -8
  74. package/modules/hall-ui/public/studio.html +5 -4
  75. package/modules/hall-ui/public/task.html +1 -1
  76. package/modules/hall-ui/public/thinking.css +6 -3
  77. package/modules/hall-ui/public/thinking.html +1 -1
  78. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  79. package/modules/hall-ui/public/tweak-editor.css +142 -11
  80. package/modules/hall-ui/public/tweak-editor.html +25 -9
  81. package/modules/hall-ui/public/tweak-editor.js +243 -10
  82. package/modules/hall-ui/public/watch.html +1 -1
  83. package/modules/hall-ui/public/work.html +1 -1
  84. package/modules/hall-ui/records/approval-queue.md +6 -0
  85. package/modules/hall-ui/records/tweak-editor.md +6 -0
  86. package/modules/ideas/ratify.js +36 -8
  87. package/modules/ideas/routes/ratify-goal.js +6 -0
  88. package/modules/lifecycle/db-analytics.js +79 -10
  89. package/modules/lifecycle/db-claims.js +48 -1
  90. package/modules/lifecycle/db-deps-criteria.js +94 -40
  91. package/modules/lifecycle/db-goals.js +4 -1
  92. package/modules/lifecycle/db-grade.js +9 -1
  93. package/modules/lifecycle/db-relevance-flags.js +183 -0
  94. package/modules/lifecycle/db-versions.js +4 -3
  95. package/modules/lifecycle/db.js +19 -0
  96. package/modules/lifecycle/done-when.js +5 -3
  97. package/modules/lifecycle/est-advisory.js +31 -4
  98. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  99. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  100. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  101. package/modules/lifecycle/module.json +2 -1
  102. package/modules/lifecycle/routes/claims.js +8 -1
  103. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  104. package/modules/lifecycle/routes/goals.js +1 -1
  105. package/modules/lifecycle/routes/tasks.js +27 -24
  106. package/modules/lifecycle/routes/visuals.js +9 -0
  107. package/modules/lifecycle/ship-preflight.js +7 -11
  108. package/modules/lifecycle/task-visuals.js +109 -5
  109. package/modules/npm-release/module.json +2 -1
  110. package/modules/npm-release/preview/commands.js +81 -0
  111. package/modules/npm-release/preview/divert.js +95 -0
  112. package/modules/npm-release/preview/env.js +78 -0
  113. package/modules/npm-release/preview/proxy.js +107 -0
  114. package/modules/npm-release/preview/runtime.js +83 -0
  115. package/modules/npm-release/preview/supervisor.js +175 -0
  116. package/modules/npm-release/public/work.js +65 -1
  117. package/modules/npm-release/routes/preview.js +133 -0
  118. package/modules/provisioning/app-status.js +83 -0
  119. package/modules/provisioning/migrations/provisioning_028_render_standup.sql +46 -0
  120. package/modules/provisioning/module.json +4 -2
  121. package/modules/provisioning/pollers/app-liveness.js +110 -0
  122. package/modules/provisioning/provisioning.js +5 -5
  123. package/modules/provisioning/render-lookup.js +68 -0
  124. package/modules/provisioning/render-standup.js +134 -0
  125. package/modules/provisioning/routes/render-standup.js +165 -0
  126. package/modules/provisioning/starter-bundles.js +4 -1
  127. package/modules/public-landing/public/account-private.states.json +14 -0
  128. package/modules/public-landing/public/account.html +513 -0
  129. package/modules/public-landing/public/account.probes.json +37 -0
  130. package/modules/public-landing/public/account.states.json +16 -0
  131. package/modules/public-landing/public/index.html +5 -2
  132. package/modules/public-landing/public/projects.html +661 -18
  133. package/modules/public-landing/public/projects.states.json +8 -1
  134. package/modules/render-deploy/deploys.js +203 -0
  135. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  136. package/modules/render-deploy/module.json +25 -0
  137. package/modules/render-deploy/public/deploy.css +10 -0
  138. package/modules/render-deploy/public/deploy.js +249 -0
  139. package/modules/render-deploy/render.js +56 -0
  140. package/modules/render-deploy/routes/act.js +88 -0
  141. package/modules/render-deploy/routes/app.js +65 -0
  142. package/modules/render-deploy/routes/history.js +38 -0
  143. package/modules/specialities/db.js +27 -8
  144. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  145. package/modules/specialities/routes/specialities.js +51 -3
  146. package/modules/specialities/skills.js +94 -0
  147. package/modules/specialities/specialities.js +56 -9
  148. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  149. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  150. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  151. package/modules/ui-design/kit/lib.js +3 -1
  152. package/modules/ui-design/kit/serve.js +96 -0
  153. package/package-lock.json +2 -2
  154. package/package.json +1 -1
  155. package/release-notes.json +173 -0
  156. package/scripts/gds/agents-sync.js +13 -3
  157. package/scripts/gds/autobongos-run.js +80 -2
  158. package/scripts/gds/autobongos-service.cmd +12 -0
  159. package/scripts/gds/copy-apply.js +14 -0
  160. package/scripts/gds/dev-box-guard.js +3 -2
  161. package/scripts/gds/fitness.js +9 -0
  162. package/scripts/gds/grade-correlation-audit.js +42 -12
  163. package/scripts/gds/grade-replay.js +1 -1
  164. package/scripts/gds/provision-core-upgrade.js +38 -7
  165. package/scripts/gds/provision-pin-key.js +176 -0
  166. package/scripts/gds/provision-render.js +189 -0
  167. package/scripts/gds/provision-teardown.js +9 -2
  168. package/scripts/gds/provision-units.js +31 -0
  169. package/scripts/gds/provision.js +12 -12
  170. package/scripts/gds/publish-manifest.js +1 -0
  171. package/scripts/gds/render-api.js +197 -0
  172. package/scripts/gds/render-payload.js +84 -0
  173. package/scripts/gds/run-unit-tests.js +10 -0
  174. package/scripts/gds/ship-finish.js +19 -20
  175. package/scripts/gds/smoke-dependencies.sh +37 -8
  176. package/scripts/gds/status.js +12 -4
  177. package/scripts/gds/upgrade.js +2 -2
  178. package/scripts/public-mirror-export.js +7 -1
  179. package/src/bongos/module-scope-map.js +13 -0
  180. package/src/bongos/serve-internal.js +3 -1
  181. package/src/module-api.js +10 -1
  182. package/src/platform-server.js +39 -0
  183. package/tests/account_privacy_flags.mjs +10 -6
  184. package/tests/agents_authoring.mjs +15 -1
  185. package/tests/agents_sync.mjs +106 -5
  186. package/tests/agents_validate.mjs +45 -0
  187. package/tests/api_path_404.mjs +6 -0
  188. package/tests/autobongos_cadence.mjs +7 -0
  189. package/tests/autobongos_loop.mjs +136 -1
  190. package/tests/autonomy_gauge.mjs +62 -0
  191. package/tests/blocker_hall_live_proof.mjs +275 -0
  192. package/tests/claim_gate_ci_unblock.mjs +116 -0
  193. package/tests/claim_gate_rebase.mjs +15 -3
  194. package/tests/collab_page.mjs +10 -0
  195. package/tests/conductor_main_e2e.mjs +97 -0
  196. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  197. package/tests/core_upgrade_runner.mjs +1 -0
  198. package/tests/dependency_writes_atomic.mjs +191 -0
  199. package/tests/deploy_page_projects.mjs +2 -1
  200. package/tests/effective_visibility_predicate.mjs +15 -0
  201. package/tests/est_advisory.mjs +27 -0
  202. package/tests/goal_map_page.mjs +26 -0
  203. package/tests/goal_task_relevance.mjs +518 -0
  204. package/tests/goal_working_area.mjs +146 -0
  205. package/tests/grade_attempts.mjs +61 -0
  206. package/tests/grade_attribution.mjs +23 -0
  207. package/tests/grade_correlation_audit.mjs +54 -1
  208. package/tests/grader_reader_lens.mjs +55 -2
  209. package/tests/grader_root_outage.mjs +6 -1
  210. package/tests/grader_subagent_tools_arg.mjs +102 -0
  211. package/tests/hall_approval_queue.mjs +54 -2
  212. package/tests/hall_settings_world.mjs +3 -1
  213. package/tests/hall_tweak_editor.mjs +316 -11
  214. package/tests/hub_account_page.mjs +394 -0
  215. package/tests/idea_detail_page.mjs +64 -0
  216. package/tests/idea_goal_ratification.mjs +64 -0
  217. package/tests/idea_spark_hall.mjs +60 -9
  218. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  219. package/tests/landing_page.mjs +6 -3
  220. package/tests/module-scope-map.mjs +31 -8
  221. package/tests/module_api.mjs +1 -0
  222. package/tests/module_loader.mjs +1 -1
  223. package/tests/nav_permission_atoms.mjs +1 -1
  224. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  225. package/tests/npm_release_preview_commands.mjs +89 -0
  226. package/tests/npm_release_preview_divert.mjs +164 -0
  227. package/tests/npm_release_preview_env.mjs +100 -0
  228. package/tests/npm_release_preview_proxy.mjs +162 -0
  229. package/tests/npm_release_preview_routes.mjs +208 -0
  230. package/tests/npm_release_preview_supervisor.mjs +203 -0
  231. package/tests/pin_write_key.mjs +383 -0
  232. package/tests/planning_session_skill.mjs +27 -0
  233. package/tests/platform_boot.mjs +71 -0
  234. package/tests/profile_nudge_links.mjs +86 -0
  235. package/tests/profile_ui_cross_project.mjs +1 -1
  236. package/tests/projects_hub.mjs +2 -2
  237. package/tests/projects_hub_app_status.mjs +249 -0
  238. package/tests/projects_hub_app_step.mjs +282 -52
  239. package/tests/projects_hub_render_connect.mjs +295 -0
  240. package/tests/provision_render.mjs +362 -0
  241. package/tests/provision_settings_apply.mjs +62 -0
  242. package/tests/provisioning_app_status.mjs +204 -0
  243. package/tests/provisioning_render_route.mjs +336 -0
  244. package/tests/provisioning_settings_apply.mjs +1 -1
  245. package/tests/provisioning_teardown_intent.mjs +2 -2
  246. package/tests/render_api.mjs +153 -0
  247. package/tests/render_check.mjs +2 -1
  248. package/tests/render_deploy.mjs +495 -0
  249. package/tests/ship_preflight.mjs +61 -8
  250. package/tests/smoke_dependencies_witness.mjs +46 -0
  251. package/tests/speciality_skills.mjs +214 -0
  252. package/tests/studio_room_height.mjs +70 -0
  253. package/tests/task_detail_includes.mjs +10 -0
  254. package/tests/task_visuals_instance_root.mjs +150 -0
  255. package/tests/tweak_batch_apply.mjs +21 -0
  256. package/tests/ui_design_kit.mjs +36 -4
  257. package/tests/upgrade.mjs +1 -1
  258. 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
- const issues = Array.isArray(w.issues) ? w.issues : [];
61
- const substantive = issues.some((i) => i && (i.severity === 'blocker' || i.severity === 'major'));
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
- return row ? { task_id: row.task_id, task_status: task_status || null, passed: !!(grade && grade.passed === true), workers: row.workers } : null;
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
- const panels = (dataset.grades || [])
160
- .map((g) => ({ task_id: String(g.task_id), votes: panelVotes(g.workers) }))
161
- .filter((p) => Object.keys(p.votes).length > 0);
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 = panels
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 grade(s), minimum n = ${r.min_n}`);
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). Only each task\'s LATEST grade keeps per-worker');
251
- out.push('verdicts (grade_attempts stores pass/score only), so a re-graded task counts once, as its final round.');
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
- // THE LIMIT, stated so nobody mistakes this for durable: the owner's token is kept for an
333
- // hour after they sign in with repository access (GITHUB_TOKEN_TTL_MS in src/bongos/db.js
334
- // — one wizard sitting). An upgrade days later usually finds none; the box's own remote
335
- // is then tried, and it can only read a private repo. The note says so in words.
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
- return miss(fault === 'refused' ? 'no_access' : fault, gitLine(r));
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
- } else log(` would revoke the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name} if present`);
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) {