@bongos/core 1.19.1081 → 1.20.2

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 (253) hide show
  1. package/.bongos-core.json +528 -193
  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 +32 -0
  5. package/clients/bongos-client/index.cjs +32 -0
  6. package/clients/bongos-client/index.d.ts +51 -6
  7. package/clients/bongos-client/index.mjs +32 -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/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  14. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  15. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  16. package/docs/adr/README.md +3 -0
  17. package/docs/api/openapi.json +851 -19
  18. package/docs/api-reference.md +33 -9
  19. package/docs/architecture.md +6 -2
  20. package/docs/copy-inventory.md +620 -553
  21. package/docs/copy-registry.json +1467 -820
  22. package/docs/file-map.md +2 -0
  23. package/docs/module-api-changelog.md +8 -0
  24. package/docs/modules-contract.md +1 -0
  25. package/docs/page-inventory.json +38 -4
  26. package/docs/page-readings.json +1666 -1558
  27. package/migrations/core_260_goals_working_area.sql +63 -0
  28. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  29. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  30. package/modules/agents/lib/validate.js +43 -0
  31. package/modules/autonomy/gauge.js +38 -2
  32. package/modules/grading/grader-subagent.js +37 -2
  33. package/modules/grading/grader-workers/reader.js +65 -5
  34. package/modules/hall-ui/public/approval-queue.css +33 -7
  35. package/modules/hall-ui/public/approval-queue.js +9 -2
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/brand-holes.js +121 -0
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/collab.js +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +6 -1
  44. package/modules/hall-ui/public/deploy.js +22 -8
  45. package/modules/hall-ui/public/diagrams.html +1 -1
  46. package/modules/hall-ui/public/dom-utils.js +20 -0
  47. package/modules/hall-ui/public/drachmae.html +1 -1
  48. package/modules/hall-ui/public/fleet.html +1 -1
  49. package/modules/hall-ui/public/gate.html +1 -1
  50. package/modules/hall-ui/public/goal-inbox.js +1 -1
  51. package/modules/hall-ui/public/goals.html +1 -1
  52. package/modules/hall-ui/public/government.html +1 -1
  53. package/modules/hall-ui/public/idea.html +1 -1
  54. package/modules/hall-ui/public/idea.js +28 -0
  55. package/modules/hall-ui/public/ideas.html +1 -1
  56. package/modules/hall-ui/public/ideas.js +19 -3
  57. package/modules/hall-ui/public/index.html +5 -1
  58. package/modules/hall-ui/public/modules.html +1 -1
  59. package/modules/hall-ui/public/primer.html +1 -1
  60. package/modules/hall-ui/public/profile-nudge.js +21 -4
  61. package/modules/hall-ui/public/profile.html +1 -1
  62. package/modules/hall-ui/public/profile.js +1 -1
  63. package/modules/hall-ui/public/project-settings.html +1 -1
  64. package/modules/hall-ui/public/ranks.html +1 -1
  65. package/modules/hall-ui/public/roadmap.html +1 -1
  66. package/modules/hall-ui/public/roster.html +1 -1
  67. package/modules/hall-ui/public/sessions.html +1 -1
  68. package/modules/hall-ui/public/settings.html +19 -30
  69. package/modules/hall-ui/public/settings.js +24 -177
  70. package/modules/hall-ui/public/settings.states.json +1 -1
  71. package/modules/hall-ui/public/shell.js +4 -2
  72. package/modules/hall-ui/public/studio.css +14 -8
  73. package/modules/hall-ui/public/studio.html +5 -4
  74. package/modules/hall-ui/public/task.html +1 -1
  75. package/modules/hall-ui/public/thinking.css +6 -3
  76. package/modules/hall-ui/public/thinking.html +1 -1
  77. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  78. package/modules/hall-ui/public/tweak-editor.css +142 -11
  79. package/modules/hall-ui/public/tweak-editor.html +25 -9
  80. package/modules/hall-ui/public/tweak-editor.js +243 -10
  81. package/modules/hall-ui/public/watch.html +1 -1
  82. package/modules/hall-ui/public/work.html +1 -1
  83. package/modules/hall-ui/records/approval-queue.md +6 -0
  84. package/modules/hall-ui/records/tweak-editor.md +6 -0
  85. package/modules/ideas/ratify.js +36 -8
  86. package/modules/ideas/routes/ratify-goal.js +6 -0
  87. package/modules/lifecycle/db-analytics.js +79 -10
  88. package/modules/lifecycle/db-claims.js +48 -1
  89. package/modules/lifecycle/db-deps-criteria.js +94 -40
  90. package/modules/lifecycle/db-goals.js +4 -1
  91. package/modules/lifecycle/db-grade.js +9 -1
  92. package/modules/lifecycle/db-relevance-flags.js +183 -0
  93. package/modules/lifecycle/db-versions.js +4 -3
  94. package/modules/lifecycle/db.js +19 -0
  95. package/modules/lifecycle/done-when.js +5 -3
  96. package/modules/lifecycle/est-advisory.js +31 -4
  97. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  98. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  99. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  100. package/modules/lifecycle/module.json +2 -1
  101. package/modules/lifecycle/routes/claims.js +8 -1
  102. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  103. package/modules/lifecycle/routes/goals.js +1 -1
  104. package/modules/lifecycle/routes/tasks.js +27 -24
  105. package/modules/lifecycle/routes/visuals.js +9 -0
  106. package/modules/lifecycle/ship-preflight.js +7 -11
  107. package/modules/lifecycle/task-visuals.js +109 -5
  108. package/modules/npm-release/module.json +2 -1
  109. package/modules/npm-release/preview/commands.js +81 -0
  110. package/modules/npm-release/preview/divert.js +95 -0
  111. package/modules/npm-release/preview/env.js +78 -0
  112. package/modules/npm-release/preview/proxy.js +107 -0
  113. package/modules/npm-release/preview/runtime.js +83 -0
  114. package/modules/npm-release/preview/supervisor.js +175 -0
  115. package/modules/npm-release/public/work.js +65 -1
  116. package/modules/npm-release/routes/preview.js +133 -0
  117. package/modules/provisioning/app-status.js +83 -0
  118. package/modules/provisioning/module.json +2 -1
  119. package/modules/provisioning/pollers/app-liveness.js +110 -0
  120. package/modules/provisioning/render-lookup.js +68 -0
  121. package/modules/provisioning/render-standup.js +44 -1
  122. package/modules/provisioning/routes/render-standup.js +81 -6
  123. package/modules/provisioning/starter-bundles.js +4 -1
  124. package/modules/public-landing/public/account-private.states.json +14 -0
  125. package/modules/public-landing/public/account.html +513 -0
  126. package/modules/public-landing/public/account.probes.json +37 -0
  127. package/modules/public-landing/public/account.states.json +16 -0
  128. package/modules/public-landing/public/index.html +5 -2
  129. package/modules/public-landing/public/projects.html +661 -18
  130. package/modules/public-landing/public/projects.states.json +8 -1
  131. package/modules/render-deploy/deploys.js +203 -0
  132. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  133. package/modules/render-deploy/module.json +25 -0
  134. package/modules/render-deploy/public/deploy.css +10 -0
  135. package/modules/render-deploy/public/deploy.js +249 -0
  136. package/modules/render-deploy/render.js +56 -0
  137. package/modules/render-deploy/routes/act.js +88 -0
  138. package/modules/render-deploy/routes/app.js +65 -0
  139. package/modules/render-deploy/routes/history.js +38 -0
  140. package/modules/specialities/db.js +27 -8
  141. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  142. package/modules/specialities/routes/specialities.js +51 -3
  143. package/modules/specialities/skills.js +94 -0
  144. package/modules/specialities/specialities.js +56 -9
  145. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  146. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  147. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  148. package/modules/ui-design/kit/lib.js +3 -1
  149. package/modules/ui-design/kit/serve.js +96 -0
  150. package/package-lock.json +2 -2
  151. package/package.json +1 -1
  152. package/release-notes.json +169 -0
  153. package/scripts/gds/agents-sync.js +13 -3
  154. package/scripts/gds/autobongos-run.js +80 -2
  155. package/scripts/gds/autobongos-service.cmd +12 -0
  156. package/scripts/gds/copy-apply.js +14 -0
  157. package/scripts/gds/dev-box-guard.js +3 -2
  158. package/scripts/gds/fitness.js +9 -0
  159. package/scripts/gds/grade-correlation-audit.js +42 -12
  160. package/scripts/gds/grade-replay.js +1 -1
  161. package/scripts/gds/provision-core-upgrade.js +38 -7
  162. package/scripts/gds/provision-pin-key.js +176 -0
  163. package/scripts/gds/provision-render.js +62 -31
  164. package/scripts/gds/provision-teardown.js +7 -2
  165. package/scripts/gds/provision-units.js +31 -0
  166. package/scripts/gds/provision.js +10 -10
  167. package/scripts/gds/publish-manifest.js +1 -0
  168. package/scripts/gds/render-api.js +19 -0
  169. package/scripts/gds/render-payload.js +39 -65
  170. package/scripts/gds/run-unit-tests.js +10 -0
  171. package/scripts/gds/ship-finish.js +19 -20
  172. package/scripts/gds/smoke-dependencies.sh +37 -8
  173. package/scripts/gds/status.js +12 -4
  174. package/scripts/gds/upgrade.js +2 -2
  175. package/scripts/public-mirror-export.js +7 -1
  176. package/src/bongos/module-scope-map.js +13 -0
  177. package/src/bongos/serve-internal.js +3 -1
  178. package/src/module-api.js +10 -1
  179. package/src/platform-server.js +39 -0
  180. package/tests/account_privacy_flags.mjs +10 -6
  181. package/tests/agents_authoring.mjs +15 -1
  182. package/tests/agents_sync.mjs +106 -5
  183. package/tests/agents_validate.mjs +45 -0
  184. package/tests/api_path_404.mjs +6 -0
  185. package/tests/autobongos_cadence.mjs +7 -0
  186. package/tests/autobongos_loop.mjs +136 -1
  187. package/tests/autonomy_gauge.mjs +62 -0
  188. package/tests/blocker_hall_live_proof.mjs +275 -0
  189. package/tests/claim_gate_ci_unblock.mjs +116 -0
  190. package/tests/claim_gate_rebase.mjs +15 -3
  191. package/tests/collab_page.mjs +10 -0
  192. package/tests/conductor_main_e2e.mjs +97 -0
  193. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  194. package/tests/core_upgrade_runner.mjs +1 -0
  195. package/tests/dependency_writes_atomic.mjs +191 -0
  196. package/tests/deploy_page_projects.mjs +2 -1
  197. package/tests/effective_visibility_predicate.mjs +15 -0
  198. package/tests/est_advisory.mjs +27 -0
  199. package/tests/goal_map_page.mjs +26 -0
  200. package/tests/goal_task_relevance.mjs +518 -0
  201. package/tests/goal_working_area.mjs +146 -0
  202. package/tests/grade_attempts.mjs +61 -0
  203. package/tests/grade_attribution.mjs +23 -0
  204. package/tests/grade_correlation_audit.mjs +54 -1
  205. package/tests/grader_reader_lens.mjs +55 -2
  206. package/tests/grader_root_outage.mjs +6 -1
  207. package/tests/grader_subagent_tools_arg.mjs +102 -0
  208. package/tests/hall_approval_queue.mjs +54 -2
  209. package/tests/hall_settings_world.mjs +3 -1
  210. package/tests/hall_tweak_editor.mjs +316 -11
  211. package/tests/hub_account_page.mjs +394 -0
  212. package/tests/idea_detail_page.mjs +64 -0
  213. package/tests/idea_goal_ratification.mjs +64 -0
  214. package/tests/idea_spark_hall.mjs +60 -9
  215. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  216. package/tests/landing_page.mjs +6 -3
  217. package/tests/module-scope-map.mjs +31 -8
  218. package/tests/module_api.mjs +1 -0
  219. package/tests/module_loader.mjs +1 -1
  220. package/tests/nav_permission_atoms.mjs +1 -1
  221. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  222. package/tests/npm_release_preview_commands.mjs +89 -0
  223. package/tests/npm_release_preview_divert.mjs +164 -0
  224. package/tests/npm_release_preview_env.mjs +100 -0
  225. package/tests/npm_release_preview_proxy.mjs +162 -0
  226. package/tests/npm_release_preview_routes.mjs +208 -0
  227. package/tests/npm_release_preview_supervisor.mjs +203 -0
  228. package/tests/pin_write_key.mjs +383 -0
  229. package/tests/planning_session_skill.mjs +27 -0
  230. package/tests/platform_boot.mjs +71 -0
  231. package/tests/profile_nudge_links.mjs +86 -0
  232. package/tests/profile_ui_cross_project.mjs +1 -1
  233. package/tests/projects_hub.mjs +2 -2
  234. package/tests/projects_hub_app_status.mjs +249 -0
  235. package/tests/projects_hub_app_step.mjs +282 -52
  236. package/tests/projects_hub_render_connect.mjs +295 -0
  237. package/tests/provision_render.mjs +121 -28
  238. package/tests/provision_settings_apply.mjs +62 -0
  239. package/tests/provisioning_app_status.mjs +204 -0
  240. package/tests/provisioning_render_route.mjs +126 -6
  241. package/tests/render_check.mjs +2 -1
  242. package/tests/render_deploy.mjs +495 -0
  243. package/tests/rot_inbox.mjs +8 -0
  244. package/tests/ship_preflight.mjs +61 -8
  245. package/tests/smoke_dependencies_witness.mjs +46 -0
  246. package/tests/speciality_skills.mjs +214 -0
  247. package/tests/studio_room_height.mjs +70 -0
  248. package/tests/task_detail_includes.mjs +10 -0
  249. package/tests/task_visuals_instance_root.mjs +150 -0
  250. package/tests/tweak_batch_apply.mjs +21 -0
  251. package/tests/ui_design_kit.mjs +36 -4
  252. package/tests/upgrade.mjs +1 -1
  253. 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 };
@@ -1,14 +1,16 @@
1
1
  'use strict';
2
2
  // provision-render.js — the Render standup leg: the runner half of the `render-standup`
3
- // intent (task 1004183; ADR 0327, ADR 0345). Its own file because provision.js sits at
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
4
5
  // the size ratchet; the dispatch there requires it inline.
5
6
  //
6
7
  // WHAT IT DOES. Stands a project up on its OWNER's Render account, with a key the owner
7
8
  // lent for this one run:
8
- // - ADOPT links a Render service the owner already runs. It reads that service through
9
- // the workspace guard and records it. It makes NO write call to their account.
10
- // - GREENFIELD creates the Postgres and the web service (render-payload.js decides what
11
- // they are), sets the env vars that need the service's URL, and asks for a deploy.
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.
12
14
  // Ids are written to the row as each resource is created, so an owner who re-enters their
13
15
  // key after a failure resumes onto what already exists instead of creating a second one.
14
16
  //
@@ -32,7 +34,6 @@
32
34
  const { RenderApi } = require('./render-api.js');
33
35
  const payload = require('./render-payload.js');
34
36
  const { parseTargetRef } = require('./provision-repo-auth.js');
35
- const { provisionedEnvPrefix } = require('./provision-units.js');
36
37
 
37
38
  // A key waiting longer than this was lent for a run that never came. It is refused and
38
39
  // cleared rather than spent late.
@@ -56,15 +57,20 @@ async function clearBorrowedKey(db, intentId) {
56
57
  await db.query(`UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = $1`, [intentId]);
57
58
  }
58
59
 
59
- async function ownerLoginOf(db, inst) {
60
- if (!inst.owner_builder_id) return null;
61
- const { rows } = await db.query(`SELECT github_login FROM builders WHERE id = $1`, [inst.owner_builder_id]);
62
- return (rows[0] && rows[0].github_login) || null;
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());
63
65
  }
64
66
 
65
67
  async function adopt(api, inst, host, { db, log }) {
66
68
  if (!host.serviceId) throw new Error('adopting needs the id of the Render service to link');
67
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
+ }
68
74
  const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
69
75
  await recordAppHost(db, inst.id, { state: 'linked', serviceUrl: url, error: null });
70
76
  log(` [render] linked ${inst.slug} to service ${svc.id}${url ? ` (${url})` : ''} — nothing created or changed on Render`);
@@ -74,39 +80,38 @@ async function adopt(api, inst, host, { db, log }) {
74
80
  async function greenfield(api, inst, host, deps) {
75
81
  const { db, log } = deps;
76
82
  const plans = payload.plansFor(host);
83
+ const build = payload.buildFor(host);
77
84
  const repo = parseTargetRef(inst.target_ref);
78
85
  if (!repo) throw new Error('the project has no GitHub repository for Render to build from');
79
- const envPrefix = deps.envPrefix || provisionedEnvPrefix();
80
-
81
- // 1. The database — reused if a previous run got this far.
82
- let pg = host.postgresId ? await api.getPostgres(host.postgresId) : null;
83
- if (!pg) {
84
- pg = await api.createPostgres(payload.renderPostgresPayload(inst, { ownerId: api.ownerId, dbPlan: plans.dbPlan, region: plans.region }));
85
- await recordAppHost(db, inst.id, { postgresId: pg.id, dbPlan: plans.dbPlan, state: 'creating' });
86
- log(` [render] created database ${pg.id} (${plans.dbPlan})`);
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`);
87
101
  }
88
- await api.settlePostgres(pg.id, { sleepFn: deps.sleepFn });
89
- const conn = await api.postgresConnectionInfo(pg.id);
90
- const dbUrl = conn && conn.internalConnectionString;
91
- if (!dbUrl) throw new Error(`Render gave database ${pg.id} no internal connection string`);
92
102
 
93
- // 2. The web service — reused likewise.
103
+ // 2. The app's web service — reused likewise. Render starts its first deploy itself.
94
104
  let svc = host.serviceId ? await api.getService(host.serviceId) : null;
95
105
  if (!svc) {
96
- const firstAdmin = (await ownerLoginOf(db, inst)) || repo.owner;
97
106
  const created = await api.createService(payload.renderServicePayload(inst, {
98
107
  ownerId: api.ownerId, repoUrl: `https://github.com/${repo.owner}/${repo.name}`, branch: host.branch,
99
- webPlan: plans.webPlan, region: plans.region, dbUrl, secretKey: payload.freshSecretKey(), envPrefix, firstAdmin,
108
+ webPlan: plans.webPlan, region: plans.region, build, dbUrl,
100
109
  }));
101
110
  svc = created.service;
102
111
  await recordAppHost(db, inst.id, { serviceId: svc.id, webPlan: plans.webPlan, region: plans.region, state: 'creating' });
103
- log(` [render] created web service ${svc.id} (${plans.webPlan})`);
112
+ log(` [render] created web service ${svc.id} (${plans.webPlan}, ${build.runtime})`);
104
113
  }
105
114
  const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
106
-
107
- // 3. What needs the URL, then one deploy that picks it all up.
108
- for (const { key, value } of payload.renderAfterCreateEnv(url, { envPrefix })) await api.putEnvVar(svc.id, key, value);
109
- await api.triggerDeploy(svc.id);
110
115
  await recordAppHost(db, inst.id, { state: 'deploying', serviceUrl: url, error: null });
111
116
  log(` [render] ${inst.slug} is deploying at ${url}`);
112
117
  return { ok: true };
@@ -144,6 +149,32 @@ async function renderStandupInstance(inst, deps, intent) {
144
149
  }
145
150
  }
146
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
+
147
178
  // Teardown's half (ADR 0327 §3): forget the Render app, never delete it. The ids stay on
148
179
  // the row so an operator can still say which services were the project's; the owner
149
180
  // deletes them in their own Render dashboard if they want to stop paying.
@@ -155,4 +186,4 @@ async function forgetRenderApp(inst, { db, log, apply }) {
155
186
  if (apply) await recordAppHost(db, inst.id, { state: 'disconnected' });
156
187
  }
157
188
 
158
- module.exports = { renderStandupInstance, forgetRenderApp, clearBorrowedKey, recordAppHost, KEY_TTL_MS };
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');
@@ -63,12 +64,16 @@ async function teardownInstance(inst, deps) {
63
64
  await ghRepos.deleteDeployKey(token, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
64
65
  log(` revoked the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name}`);
65
66
  }
66
- } 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`);
67
70
  } else {
68
- 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)`);
69
72
  }
70
73
  } catch (e) { log(` (deploy-key revoke skipped: ${e && e.message})`); }
71
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 });
72
77
  }
73
78
  }
74
79
  if (apply) {
@@ -390,6 +390,36 @@ function upsertEnvVars(body, vars) {
390
390
  return text;
391
391
  }
392
392
 
393
+ // Env names a rename retired, carried forward ON THE BOX by the settings push (task
394
+ // 1004395). The prelaunch-gate rename (task 1003152, ADR 0182) made PRELAUNCH_PASSWORD
395
+ // primary and kept STEALTH_PASSWORD as an alias; the alias can only be dropped (task
396
+ // 1003830) once every live web.env carries the new name, and before this the only way
397
+ // to add it was the owner's SSH (blocker 1000147). [from, to] pairs.
398
+ const ENV_RENAMES = Object.freeze([['STEALTH_PASSWORD', 'PRELAUNCH_PASSWORD']]);
399
+
400
+ // Which renamed names does this web.env still hold ONLY under the old name? Returns
401
+ // { vars, carried }: `vars` is the new name → the old line's right-hand side, copied
402
+ // verbatim (quotes and all, so systemd reads the same value), for upsertEnvVars;
403
+ // `carried` names the pairs and is the ONLY part that may reach a log or an event —
404
+ // the value is a secret read on the box and it never leaves it. A new name that is
405
+ // already set wins, even when it differs. The last line of a key counts, as systemd
406
+ // reads it. PURE.
407
+ function renamedEnvCarry(body) {
408
+ const lines = String(body == null ? '' : body).split(/\r?\n/);
409
+ const rhs = (key) => {
410
+ let v = null;
411
+ for (const l of lines) if (l.startsWith(`${key}=`)) v = l.slice(key.length + 1);
412
+ return v;
413
+ };
414
+ const isSet = (v) => v !== null && !['', '""', "''"].includes(v.trim());
415
+ const vars = {}, carried = [];
416
+ for (const [from, to] of ENV_RENAMES) {
417
+ const oldV = rhs(from);
418
+ if (isSet(oldV) && !isSet(rhs(to))) { vars[to] = oldV; carried.push(`${from} -> ${to}`); }
419
+ }
420
+ return { vars, carried };
421
+ }
422
+
393
423
  // ── Cross-site identity: federate a hosted instance's sign-in through the hub ───
394
424
  // (ADR 0141 §4, task #1002109). The control plane IS the hub (cloudbongos.com), so
395
425
  // registration is a direct in-process call against the hub pool — no HTTP hop.
@@ -415,6 +445,7 @@ module.exports = {
415
445
  serviceUnitPath,
416
446
  settingsConsumed,
417
447
  settingsEnvVarsFor,
448
+ renamedEnvCarry,
418
449
  upsertEnvVars,
419
450
  webEnvBody,
420
451
  webEnvPath,