@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
@@ -24,6 +24,12 @@
24
24
  "in-manage-card": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mCard", "#mCardPick .pick .pickPreview .cardBand", "#mCardPick [data-card-save]", "#mCardWhat summary"], "count": [["#mCardPick .pickCells[role=\"radiogroup\"]", 1], ["#mCardPick .pickCell", 5], ["#mCardPick .pickCell.on", 1]], "checked": "#mCardPick input[name=\"mCardBackdrop\"][value=\"\"]", "attr": [["#mCardPick .cardBand", "data-own", ""]] } },
25
25
  "in-manage-kind": { "auth": true, "url": "/projects?view=manage&id=101", "expect": {"visible":["#mKind","#mKindPick .modBand","#mKindWhat summary"],"count":[["#mKindPick .modOpt",5],["#mKindPick .modOpt[aria-pressed=\"true\"]",0]],"text":[["#mKindPick .modOpt b","Game"],["#mKindPick .finePrint","This project has never said what kind it is. Saying so puts the word beside its name wherever it is listed; it changes nothing else."]]} },
26
26
  "in-manage-invite": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mInvite", "#mInvite h2", "#mInviteWho", "#mInvite [data-invite-send]"] } },
27
+ "in-manage-app-live": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStat","#mApp .appVars"],"hidden":[]} },
28
+ "in-manage-app-building": { "auth": true, "url": "/projects?view=manage&id=102", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStages"],"hidden":[]} },
29
+ "in-manage-app-failed": { "auth": true, "url": "/projects?view=manage&id=105", "actions": [["wait",900],["focus","#mApp .appRetryBtn"]], "expect": {"visible":["#mAppCard","#mApp p.err","#mApp .appRetryBtn"],"hidden":["#mApp .appRetry"]} },
30
+ "in-manage-app-retry": { "auth": true, "url": "/projects?view=manage&id=105", "actions": [["wait",900],["click","#mApp .appRetryBtn"],["wait",200]], "expect": {"visible":["#mApp .appRetry","#mApp .appRetryKey"],"hidden":[]} },
31
+ "in-manage-app-offer": { "auth": true, "url": "/projects?view=manage&id=103", "actions": [["wait",900],["focus","#mAppConnectOpen"]], "expect": {"visible":["#mAppCard","#mAppConnect","#mAppConnectOpen"],"hidden":["#mAppConnectForm"]} },
32
+ "in-manage-app-connect": { "auth": true, "url": "/projects?view=manage&id=103", "actions": [["wait",900],["click","#mAppConnectOpen"],["wait",200]], "expect": {"visible":["#mAppConnectForm","#m-renderSetup","#m-f-renderKey","#mAppConnectGo"],"hidden":["#mAppConnectOpen","#m-renderWs"]} },
27
33
  "in-manage-snag": { "auth": true, "url": "/projects?view=manage&id=103", "expect": { "visible": ["#mMain", "#mErr", "#mActs"], "hidden": ["#mAddress"], "text": [["#mName", "tidepool"]] } },
28
34
  "in-manage-gone": { "auth": true, "url": "/projects?view=manage&id=104", "expect": { "visible": ["#mMain", "#mActs"], "hidden": ["#mErr", "#mAddress"], "text": [["#mName", "old-lantern"]] } },
29
35
  "in-manage-missing": { "auth": true, "url": "/projects?view=manage&id=999", "expect": { "visible": ["#mMissing"], "hidden": ["#mMain", "#mGate"] } },
@@ -36,7 +42,8 @@
36
42
  "in-new-modules-search": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["fill","#modQ","discord"],["wait",200]], "expect": {"visible":["#panel-modules","#modQ"],"value":[["#modQ","discord"]],"count":[["#modCards .tpill",1],["#modCards .tpill[data-mod=\"discord\"]",1]],"text":[["#modFilterNote","Showing 1 of 3. 2 extras are switched on, including any this search is hiding."]]} },
37
43
  "in-new-home": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-home"],"hidden":["#panel-detail","#qWrap"]} },
38
44
  "in-new-address": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-address"],"hidden":["#panel-home","#qWrap"]} },
39
- "in-new-app": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-app","#appOpts","#appOptRender .soonTag"],"hidden":["#panel-address","#panel-review","#qWrap"]} },
45
+ "in-new-app": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-app","#appOpts","#appOptRender"],"hidden":["#panel-address","#panel-review","#qWrap"]} },
46
+ "in-new-app-render": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#appOptRender input"],["wait",300]], "expect": {"visible":["#panel-app","#renderSetup","#f-renderKey","#renderCheck"],"hidden":["#renderWs","#renderSvc","#renderNew"]} },
40
47
  "in-new-review": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-review","#reviewList li"],"hidden":["#panel-address","#qWrap"]} },
41
48
  "in-new-done": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500]], "expect": {"visible":["#panel-done","#liveProgress .prog","#humanSteps li"],"hidden":["#panel-review","#qWrap"]} },
42
49
  "in-new-resume-offer": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500],["click","#projTrig"],["wait",350],["click","#menuProjects [data-nav=\"new\"]"],["wait",500]], "expect": {"visible":["#panel-name","#wizResume","#wizResumeGo"],"hidden":["#panel-done"],"text":[["#wizResumeName","Mercury"]]} },
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ // modules/render-deploy/deploys.js — the reading behind a Render project's deploy page
4
+ // (task 1004270): the service's Render deploys, each tied to its commit and to the Bongos tasks
5
+ // in it, with what is live now marked. Pure shaping plus one ledger query; every call to Render
6
+ // goes through the client the caller hands in (scripts/gds/render-api.js, the ONE Render client,
7
+ // with its ownerId guard), so this file never holds a key and never talks to the network.
8
+ //
9
+ // WHAT "THE TASKS IN A COMMIT RANGE" MEANS HERE. Render's API names the commit a deploy built
10
+ // and nothing about the commits between two deploys, and the hall has no clone of the app's
11
+ // repository to diff. So a deploy's tasks are the union of two readings, and each task says
12
+ // which one named it:
13
+ // named the commit message names the task (`task 1004057`, or a `task-1004057` branch in a
14
+ // merge message), which is how a Bongos ship writes its merge;
15
+ // window the ledger shipped the task after the previous deploy's commit and up to this
16
+ // deploy's commit. An approximation of a range, said as one, never as a diff.
17
+ // The oldest deploy Render lists has no earlier one to bound it, so it carries `named` only and
18
+ // the page says its range starts there.
19
+ //
20
+ // WHAT RENDER CAN AND CANNOT SHOW. Render has no address for "this earlier deploy, running", and
21
+ // the page does not invent one: previewOf() answers that it cannot, with the reason a reader can
22
+ // act on. Rolling back to a deploy is the only way to see it run, and the page says that too.
23
+
24
+ const KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
25
+ const OWNER_RE = /^(tea|usr)-[a-z0-9]{8,64}$/;
26
+ const SERVICE_RE = /^srv-[a-z0-9]{8,64}$/;
27
+ const DEPLOY_RE = /^dep-[a-z0-9]{8,64}$/;
28
+
29
+ const HISTORY_LIMIT = 30;
30
+
31
+ // Render's deploy statuses, in words a person would say (voice.md V2: no slug shown as is).
32
+ const STATUS_WORDS = Object.freeze({
33
+ created: 'Queued',
34
+ build_in_progress: 'Building',
35
+ update_in_progress: 'Starting',
36
+ pre_deploy_in_progress: 'Running its pre-deploy step',
37
+ live: 'Live',
38
+ deactivated: 'Replaced by a newer deploy',
39
+ build_failed: 'The build failed',
40
+ update_failed: 'It did not start',
41
+ pre_deploy_failed: 'Its pre-deploy step failed',
42
+ canceled: 'Canceled',
43
+ });
44
+
45
+ function statusWords(status) {
46
+ return STATUS_WORDS[status] || 'Unknown to this page';
47
+ }
48
+
49
+ // Only a deploy that once ran and was then replaced can be rolled back to. A build that failed
50
+ // never served; one still in flight is not finished; the live one is already what is running.
51
+ const ROLLBACK_STATUSES = Object.freeze(['deactivated']);
52
+
53
+ /** The task ids a commit message names: "task 1004057", "task-1004057", "task/1004057". */
54
+ function taskIdsIn(message) {
55
+ const ids = [];
56
+ const re = /\btask[\s/_-]{0,2}#?(\d{3,12})\b/gi;
57
+ let m;
58
+ while ((m = re.exec(String(message || ''))) !== null) if (!ids.includes(m[1])) ids.push(m[1]);
59
+ return ids;
60
+ }
61
+
62
+ /**
63
+ * Can this deploy be looked at running? Render answers no, and the page says why.
64
+ * `service.previews.generation` is Render's preview-environments setting: it makes a short-lived
65
+ * copy of the service for an open pull request, never for an earlier deploy, so even a plan that
66
+ * has it does not give this page an address to show. Kept as a function so a verified per-deploy
67
+ * address can replace the answer in one place.
68
+ */
69
+ function previewOf(service) {
70
+ const gen = service && service.previews && service.previews.generation;
71
+ const has = gen && gen !== 'off';
72
+ return {
73
+ available: false,
74
+ reason: has
75
+ ? 'Render gives an earlier deploy no address of its own. Its preview environments are copies made for pull requests. The only way to see this deploy running is to roll back to it.'
76
+ : 'This service has no preview environments, and Render gives an earlier deploy no address of its own on any plan. The only way to see this deploy running is to roll back to it.',
77
+ };
78
+ }
79
+
80
+ /** What a press costs, said in one sentence per action. The cost lands on the owner's account. */
81
+ function costOf(service) {
82
+ const plan = service && service.serviceDetails && service.serviceDetails.plan;
83
+ const where = plan ? `Your Render plan for this service (${plan}) is billed as it always is.` : 'Your Render plan for this service is billed as it always is.';
84
+ return {
85
+ deploy: `${where} A deploy runs a new build on your Render account. If your workspace is past the build minutes its plan includes, Render bills the extra minutes to you.`,
86
+ rollback: `${where} A rollback re-serves an earlier build and runs no new build. After a rollback Render can pause automatic deploys for the service, so check that setting in Render afterwards.`,
87
+ };
88
+ }
89
+
90
+ const ms = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? t : null; };
91
+
92
+ function shortId(id) { return String(id || '').slice(0, 7); }
93
+
94
+ /**
95
+ * Shape Render's deploys into the page's history. `ledger` is the shipped tasks (`{ id, title,
96
+ * at }`) the caller read for the span the deploys cover; `titles` maps an id to its title for the
97
+ * tasks a commit message named that the ledger window did not carry.
98
+ */
99
+ function shapeHistory({ service, deploys, ledger = [], titles = {} }) {
100
+ const list = Array.isArray(deploys) ? deploys : [];
101
+ const liveDeploy = list.find((d) => d.status === 'live') || null;
102
+ const preview = previewOf(service);
103
+ const out = list.map((d, i) => {
104
+ const commit = d.commit || {};
105
+ const at = ms(commit.createdAt) ?? ms(d.createdAt);
106
+ // The previous DIFFERENT commit: two deploys of one commit (a manual redeploy) carry nothing new.
107
+ const older = list.slice(i + 1).find((o) => o.commit && o.commit.id && o.commit.id !== commit.id) || null;
108
+ const since = older ? (ms(older.commit.createdAt) ?? ms(older.createdAt)) : null;
109
+ const tasks = [];
110
+ for (const id of taskIdsIn(commit.message)) tasks.push({ id, title: titles[id] || null, via: 'named' });
111
+ if (since !== null && at !== null) {
112
+ for (const t of ledger) {
113
+ const ta = ms(t.at);
114
+ if (ta !== null && ta > since && ta <= at && !tasks.some((x) => x.id === String(t.id))) {
115
+ tasks.push({ id: String(t.id), title: t.title || null, via: 'window' });
116
+ }
117
+ }
118
+ }
119
+ return {
120
+ id: d.id,
121
+ commit: { id: commit.id || null, short: shortId(commit.id), message: String(commit.message || '').split('\n')[0].slice(0, 300), at: commit.createdAt || null },
122
+ status: d.status,
123
+ statusWords: statusWords(d.status),
124
+ trigger: d.trigger || null,
125
+ at: d.createdAt || null,
126
+ finishedAt: d.finishedAt || null,
127
+ live: !!liveDeploy && d.id === liveDeploy.id,
128
+ canRollbackTo: ROLLBACK_STATUSES.includes(d.status),
129
+ rangeStartsHere: older === null,
130
+ tasks,
131
+ preview,
132
+ };
133
+ });
134
+ return { liveDeployId: liveDeploy ? liveDeploy.id : null, deploys: out, cost: costOf(service) };
135
+ }
136
+
137
+ // A task counts as done for the ledger once it is completed, confirmed or shipped, the same
138
+ // reading the npm-release page takes of "recent work".
139
+ const DONE_STATUSES = ['completed', 'confirmed', 'shipped'];
140
+
141
+ async function readLedger(pool, deploys) {
142
+ const times = (deploys || []).map((d) => ms(d.commit && d.commit.createdAt) ?? ms(d.createdAt)).filter((t) => t !== null);
143
+ if (times.length < 2) return [];
144
+ const { rows } = await pool.query(
145
+ `SELECT id, title, COALESCE(shipped_at, updated_at) AS at
146
+ FROM tasks
147
+ WHERE status = ANY($3::text[])
148
+ AND COALESCE(shipped_at, updated_at) > $1::timestamptz
149
+ AND COALESCE(shipped_at, updated_at) <= $2::timestamptz
150
+ ORDER BY COALESCE(shipped_at, updated_at) DESC
151
+ LIMIT 500`,
152
+ [new Date(Math.min(...times)).toISOString(), new Date(Math.max(...times)).toISOString(), DONE_STATUSES],
153
+ );
154
+ return rows.map((r) => ({ id: String(r.id), title: r.title, at: r.at instanceof Date ? r.at.toISOString() : r.at }));
155
+ }
156
+
157
+ async function readTitles(pool, ids) {
158
+ if (!ids.length) return {};
159
+ const { rows } = await pool.query('SELECT id, title FROM tasks WHERE id = ANY($1::bigint[])', [ids]);
160
+ return Object.fromEntries(rows.map((r) => [String(r.id), r.title]));
161
+ }
162
+
163
+ /**
164
+ * The page's whole reading. `client` is a RenderApi already bound to the app's workspace; the
165
+ * service is read through its guard first, so a service in another workspace never gets this far.
166
+ */
167
+ async function readHistory({ pool, client, app, limit = HISTORY_LIMIT }) {
168
+ const service = await client.getService(app.service_id);
169
+ const deploys = await client.listDeploys(app.service_id, { limit });
170
+ const ledger = await readLedger(pool, deploys);
171
+ const named = [...new Set(deploys.flatMap((d) => taskIdsIn(d.commit && d.commit.message)))];
172
+ const titles = await readTitles(pool, named);
173
+ const details = (service && service.serviceDetails) || {};
174
+ return {
175
+ app: { serviceId: app.service_id, serviceName: (service && service.name) || app.service_name || null, ownerId: app.owner_id },
176
+ service: { name: (service && service.name) || null, plan: details.plan || null, url: details.url || null },
177
+ ...shapeHistory({ service, deploys, ledger, titles }),
178
+ };
179
+ }
180
+
181
+ async function getApp(pool) {
182
+ const { rows } = await pool.query('SELECT owner_id, service_id, service_name, connected_at FROM render_deploy_app WHERE id = 1');
183
+ return rows[0] || null;
184
+ }
185
+
186
+ async function saveApp(pool, { ownerId, serviceId, serviceName, builderId }) {
187
+ const { rows } = await pool.query(
188
+ `INSERT INTO render_deploy_app (id, owner_id, service_id, service_name, connected_by)
189
+ VALUES (1, $1, $2, $3, $4)
190
+ ON CONFLICT (id) DO UPDATE
191
+ SET owner_id = EXCLUDED.owner_id, service_id = EXCLUDED.service_id, service_name = EXCLUDED.service_name,
192
+ connected_by = EXCLUDED.connected_by, connected_at = now()
193
+ RETURNING owner_id, service_id, service_name, connected_at`,
194
+ [ownerId, serviceId, serviceName || null, builderId == null ? null : builderId],
195
+ );
196
+ return rows[0];
197
+ }
198
+
199
+ module.exports = {
200
+ KEY_RE, OWNER_RE, SERVICE_RE, DEPLOY_RE, HISTORY_LIMIT,
201
+ statusWords, taskIdsIn, previewOf, costOf, shapeHistory, readHistory, getApp, saveApp,
202
+ ROLLBACK_STATUSES,
203
+ };
@@ -0,0 +1,22 @@
1
+ -- render_deploy_001_app.sql — which Render service this hall's deploy page reads (task 1004270).
2
+ --
3
+ -- ONE ROW, because a hall is one project and the module manages one app. It holds two ids and
4
+ -- nothing secret: the Render workspace (owner) the app lives in and the service. The owner's
5
+ -- Render API key is NEVER stored anywhere (ADR 0327 §2): the page asks for it on each request,
6
+ -- the server spends it on that one request and drops it. Storing the workspace id is what lets
7
+ -- every call go through the ownerId guard (scripts/gds/render-api.js) with a workspace the owner
8
+ -- NAMED, never one picked out of the key's own list.
9
+ --
10
+ -- Additive only (docs/modules-contract.md): one new table, the module's own prefix.
11
+
12
+ CREATE TABLE IF NOT EXISTS render_deploy_app (
13
+ id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
14
+ owner_id text NOT NULL,
15
+ service_id text NOT NULL,
16
+ service_name text,
17
+ connected_by bigint,
18
+ connected_at timestamptz NOT NULL DEFAULT now()
19
+ );
20
+
21
+ COMMENT ON TABLE render_deploy_app IS
22
+ 'The one Render service this hall manages (task 1004270): workspace id + service id, no secrets. The Render API key is never stored (ADR 0327 §2).';
@@ -0,0 +1,25 @@
1
+ {
2
+ "key": "render-deploy",
3
+ "title": "Render deploy",
4
+ "description": "The deploy page for a project whose app deploys through Render: the service's Render deploys, each tied to its commit and the Bongos tasks it carried, what is live now, and deploy and roll back behind a two-step confirm (task 1004270). Off in every hall until its owner turns it on.",
5
+ "version": "1.0.0",
6
+ "coreVersion": "^1.19.0",
7
+ "default": false,
8
+ "author": "Cloud Bongos",
9
+ "origin": "Cloud Bongos core",
10
+ "license": "AGPL-3.0",
11
+ "maintainer": "Cloud Bongos maintainers",
12
+ "maintenance": {
13
+ "status": "core-maintained"
14
+ },
15
+ "contributes": {
16
+ "routes": [
17
+ "app",
18
+ "history",
19
+ "act"
20
+ ],
21
+ "migrations": true
22
+ },
23
+ "provides": [],
24
+ "consumes": []
25
+ }
@@ -0,0 +1,10 @@
1
+ /* modules/render-deploy/public/deploy.css — spacing the Render deploy page needs beyond
2
+ oversight.css (task 1004270). Loaded by deploy.js beside itself, so a hall without the module
3
+ never fetches it. Spacing only: every colour, face and size is the shared row family's. */
4
+
5
+ #rd-panel .rd-key { margin: 12px 0; }
6
+ #rd-panel .rd-key input,
7
+ #rd-panel .rd-form input { display: block; width: 100%; max-width: 32rem; margin: 4px 0 8px; }
8
+ #rd-panel .rd-form { margin: 12px 0; }
9
+ #rd-panel .rd-acts { margin: 8px 0 4px; }
10
+ #rd-panel .ov-group > .ov-row__sub { margin: 8px 0 6px; }
@@ -0,0 +1,249 @@
1
+ // modules/render-deploy/public/deploy.js — the deploy page of a project whose app deploys
2
+ // through Render (task 1004270). Loaded by /deploy only when the render-deploy module is on
3
+ // (hall-ui/public/deploy.js), and rendered into #rd-panel.
4
+ //
5
+ // THE PAGE KNOWS NOTHING (deploy.js's rule, kept). Every deploy, status, task and cost sentence
6
+ // is the server's reading of Render and the ledger; nothing is compared or inferred here.
7
+ //
8
+ // THE KEY IS NEVER KEPT. Render's API key is the owner's, is account-wide, and is not stored
9
+ // anywhere (ADR 0327 §2). It lives in one variable on this page, is sent in the x-render-key
10
+ // header of each request, and is gone when the tab closes. It is never written to storage, the
11
+ // address, or the page's markup, and the field that takes it is a password field that is never
12
+ // re-rendered with a value in it.
13
+ //
14
+ // TWO STEPS, NEVER ONE (ADR 0293 D4). Pressing Deploy or Roll back only opens step two: what
15
+ // would happen, what it costs, and a confirm button built FROM the deploy that was shown, so a
16
+ // repaint cannot swap what the press does. The server holds the second step as well: the confirm
17
+ // names the deploy that was live when this step was drawn, and is refused if Render's has moved.
18
+ (() => {
19
+ 'use strict';
20
+
21
+ const API = '/api/bongos';
22
+ const client = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
23
+ const { escapeHtml } = window.OTB;
24
+ const $ = (id) => document.getElementById(id);
25
+
26
+ // The key, in memory only. See the header.
27
+ let renderKey = '';
28
+ const state = { app: undefined, hist: null, viewing: null, pending: null, busy: false, error: null, done: null };
29
+
30
+ // Voice rule V4 (docs/voice.md): a dash in QUOTED data (a task title, a commit message) is
31
+ // a comma at render, never an edit to the data.
32
+ const unDash = (raw) => String(raw == null ? '' : raw).replace(/\s*[—–]\s*/g, ', ').replace(/,\s*,/g, ',').trim();
33
+
34
+ function when(iso) {
35
+ const t = Date.parse(iso);
36
+ if (!Number.isFinite(t)) return '';
37
+ const mins = Math.round((Date.now() - t) / 60000);
38
+ if (mins < 1) return 'just now';
39
+ if (mins < 60) return `${mins}m ago`;
40
+ if (mins < 48 * 60) return `${Math.round(mins / 60)}h ago`;
41
+ return new Date(t).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
42
+ }
43
+
44
+ async function call(method, path, body) {
45
+ const headers = renderKey ? { 'x-render-key': renderKey } : {};
46
+ const res = await client.request(method, `${API}${path}`, { body, headers });
47
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw new Error('Sign in again.'); }
48
+ if (!res.ok) {
49
+ const err = (res.data && res.data.error) || {};
50
+ const e = new Error(err.message || `${path} → ${res.status}`);
51
+ e.code = err.code || null;
52
+ throw e;
53
+ }
54
+ return res.data;
55
+ }
56
+
57
+ // ---- painting -------------------------------------------------------------
58
+
59
+ function keyBoxHtml() {
60
+ return `<div class="rd-key">
61
+ <label for="rd-key">Render API key</label>
62
+ <input type="password" id="rd-key" autocomplete="off" spellcheck="false" placeholder="rnd_…">
63
+ <p class="ov-row__sub">Used for the request you make, then dropped. It is never saved, and this page forgets it when you close the tab.</p>
64
+ </div>`;
65
+ }
66
+
67
+ function connectHtml() {
68
+ return `<div class="ov-note">
69
+ <p><strong>Connect this project to its Render service.</strong> Copy the workspace id and the service id from Render, and enter your key above. The key must be able to act in that workspace.</p>
70
+ </div>
71
+ <div class="rd-form">
72
+ <label for="rd-owner">Workspace id</label>
73
+ <input type="text" id="rd-owner" autocomplete="off" spellcheck="false" placeholder="tea-…">
74
+ <label for="rd-service">Service id</label>
75
+ <input type="text" id="rd-service" autocomplete="off" spellcheck="false" placeholder="srv-…">
76
+ <button type="button" class="btn btn--primary" id="rd-connect"${state.busy ? ' disabled' : ''}>Connect</button>
77
+ </div>`;
78
+ }
79
+
80
+ function taskLine(t) {
81
+ const label = unDash(t.title) || `Task ${t.id}`;
82
+ const how = t.via === 'named' ? 'named in the commit' : 'shipped in this range';
83
+ return `<li class="ov-row ov-row--dense"><div class="ov-row__main">
84
+ <a class="ov-row__title" href="/task/${encodeURIComponent(t.id)}">${escapeHtml(label)}</a>
85
+ <p class="ov-row__sub">${escapeHtml(how)}</p></div></li>`;
86
+ }
87
+
88
+ function deployHtml(d) {
89
+ const marks = [];
90
+ if (d.live) marks.push('<strong>Live now</strong>');
91
+ marks.push(escapeHtml(d.statusWords));
92
+ const tasks = d.tasks.length
93
+ ? `<ul class="ov-rows">${d.tasks.map(taskLine).join('')}</ul>`
94
+ : `<p class="ov-row__sub">${d.rangeStartsHere ? 'No task is named in this commit, and it is the oldest deploy shown, so its range starts here.' : 'No Bongos task is named in this commit or shipped in its range.'}</p>`;
95
+ const acts = [`<button type="button" class="btn btn--sm btn--ghost" data-view="${escapeHtml(d.id)}">View</button>`];
96
+ if (d.canRollbackTo && !d.live) acts.push(`<button type="button" class="btn btn--sm" data-rollback="${escapeHtml(d.id)}">Roll back to this</button>`);
97
+ return `<div class="ov-group" data-deploy="${escapeHtml(d.id)}">
98
+ <div class="ov-group__head">
99
+ <span class="ov-group__name ov-key">${escapeHtml(d.commit.short || d.id)}</span>
100
+ <span class="ov-group__n">${d.tasks.length}</span>
101
+ <span class="ov-group__note">${marks.join(' · ')} · ${escapeHtml(when(d.at))}</span>
102
+ </div>
103
+ <p class="ov-row__sub">${escapeHtml(unDash(d.commit.message) || 'No commit message.')}</p>
104
+ ${tasks}
105
+ <div class="rd-acts">${acts.join(' ')}</div>
106
+ </div>`;
107
+ }
108
+
109
+ // Step two of a press: what would happen, what it costs, and the confirm, built from the
110
+ // deploy that was shown.
111
+ function stepTwoHtml(h) {
112
+ const p = state.pending;
113
+ if (!p) return '';
114
+ const target = p.action === 'rollback' ? h.deploys.find((d) => d.id === p.deployId) : null;
115
+ const what = p.action === 'rollback'
116
+ ? `Roll ${escapeHtml(h.app.serviceName || 'the service')} back to deploy ${escapeHtml(target ? target.commit.short : p.deployId)}. That is what your users will get next.`
117
+ : `Start a new deploy of ${escapeHtml(h.app.serviceName || 'the service')} from the latest commit on its branch.`;
118
+ return `<div class="ov-note ov-note--warn" id="rd-step2">
119
+ <p><strong>Check this before you confirm.</strong> ${what}</p>
120
+ <p>${escapeHtml(h.cost[p.action])}</p>
121
+ <p><button type="button" class="btn btn--primary" id="rd-confirm"${state.busy ? ' disabled' : ''}>${p.action === 'rollback' ? 'Confirm roll back' : 'Confirm deploy'}</button>
122
+ <button type="button" class="btn btn--ghost" id="rd-cancel">Cancel</button></p>
123
+ </div>`;
124
+ }
125
+
126
+ // "You're viewing X": the record of one deploy, and the honest word on previews.
127
+ function viewingHtml(h) {
128
+ const d = h.deploys.find((x) => x.id === state.viewing);
129
+ if (!d) return '';
130
+ return `<div class="ov-note" id="rd-viewing" role="status">
131
+ <p><strong>You’re viewing deploy ${escapeHtml(d.commit.short || d.id)}.</strong> ${d.live ? 'It is what is live now.' : 'It is not what is live now.'} This is the record of that deploy, not the running app.</p>
132
+ <p>${escapeHtml(d.preview.available ? 'A preview of this deploy is open.' : d.preview.reason)}</p>
133
+ <p><button type="button" class="btn btn--sm btn--ghost" id="rd-unview">Back to the history</button></p>
134
+ </div>`;
135
+ }
136
+
137
+ function paint() {
138
+ const el = $('rd-panel');
139
+ if (!el) return;
140
+ const h = state.hist;
141
+ let body = '';
142
+ if (state.error) body += `<div class="ov-note ov-note--warn"><p><strong>Nothing was changed.</strong> ${escapeHtml(state.error)}</p></div>`;
143
+ if (state.done) body += `<div class="ov-note"><p>${escapeHtml(state.done)}</p></div>`;
144
+ if (state.app === undefined) body += '<span class="kit-skel"></span>';
145
+ else if (state.app === null) body += connectHtml();
146
+ else if (!h) {
147
+ body += `<p class="scroll__lede">Connected to ${escapeHtml(state.app.serviceName || state.app.serviceId)}. Enter your Render key to read its deploys.</p>
148
+ <p><button type="button" class="btn btn--primary" id="rd-read"${state.busy ? ' disabled' : ''}>Read from Render</button></p>`;
149
+ } else {
150
+ body += `<p class="scroll__lede">${escapeHtml(h.app.serviceName || 'This service')}${h.service.plan ? `, on the ${escapeHtml(h.service.plan)} plan` : ''}. Each deploy is tied to its commit and to the Bongos tasks in it.</p>
151
+ ${viewingHtml(h)}
152
+ <p><button type="button" class="btn btn--primary" id="rd-deploy"${state.busy ? ' disabled' : ''}>Deploy the latest commit</button>
153
+ <button type="button" class="btn btn--ghost" id="rd-refresh"${state.busy ? ' disabled' : ''}>Read again</button></p>
154
+ ${stepTwoHtml(h)}
155
+ ${h.deploys.map(deployHtml).join('') || '<p class="ov-row__sub">Render lists no deploys for this service yet.</p>'}`;
156
+ }
157
+ // The key field is written once and kept, so a repaint never clears or re-creates it.
158
+ const list = $('rd-list');
159
+ if (list) list.innerHTML = body;
160
+ el.hidden = false;
161
+ }
162
+
163
+ function mount() {
164
+ const el = $('rd-panel');
165
+ if (!el) return false;
166
+ el.innerHTML = `<h2 class="scroll__h" id="rd-h">Render deploys</h2>
167
+ <p class="scroll__lede">This project’s app deploys through Render. Deploying or rolling back here acts on your Render account, so each one asks twice.</p>
168
+ ${keyBoxHtml()}<div id="rd-list"></div>`;
169
+ $('rd-key').addEventListener('input', (ev) => { renderKey = String(ev.target.value || '').trim(); });
170
+ return true;
171
+ }
172
+
173
+ // ---- acts -----------------------------------------------------------------
174
+
175
+ async function run(fn) {
176
+ if (state.busy) return;
177
+ state.busy = true; state.error = null; state.done = null; paint();
178
+ try { await fn(); }
179
+ catch (e) { state.error = e.message || String(e); }
180
+ finally { state.busy = false; paint(); }
181
+ }
182
+
183
+ const readHistory = () => run(async () => {
184
+ const d = await call('GET', '/render-deploy/history');
185
+ if (d.connected === false) { state.app = null; state.hist = null; return; }
186
+ if (d.needsKey) { state.app = d.app; state.hist = null; state.error = 'Enter your Render key first.'; return; }
187
+ state.app = d.app; state.hist = d; state.viewing = null; state.pending = null;
188
+ });
189
+
190
+ const connect = () => run(async () => {
191
+ const ownerId = String(($('rd-owner') || {}).value || '').trim();
192
+ const serviceId = String(($('rd-service') || {}).value || '').trim();
193
+ const d = await call('PUT', '/render-deploy/app', { ownerId, serviceId });
194
+ state.app = d.app;
195
+ const h = await call('GET', '/render-deploy/history');
196
+ if (!h.needsKey) { state.hist = h; }
197
+ });
198
+
199
+ // Step two. Closes over `p`, the press that was shown, so what is sent is what was displayed.
200
+ const confirm = () => {
201
+ const p = state.pending;
202
+ if (!p) return;
203
+ return run(async () => {
204
+ const r = await call('POST', '/render-deploy/act', { action: p.action, deployId: p.deployId, confirm: true, expectLiveDeployId: p.liveWas });
205
+ state.pending = null;
206
+ state.done = `Render accepted it${r.render ? `, and now reports the newest deploy as: ${r.render.statusWords.toLowerCase()}` : ''}. Read again to follow it.`;
207
+ const d = await call('GET', '/render-deploy/history');
208
+ if (!d.needsKey) state.hist = d;
209
+ });
210
+ };
211
+
212
+ document.addEventListener('click', (ev) => {
213
+ const t = ev.target;
214
+ if (!(t instanceof Element) || !$('rd-panel') || !$('rd-panel').contains(t)) return;
215
+ if (t.closest('#rd-connect')) return connect();
216
+ if (t.closest('#rd-read') || t.closest('#rd-refresh')) return readHistory();
217
+ if (t.closest('#rd-deploy')) { state.pending = { action: 'deploy', liveWas: state.hist.liveDeployId }; state.error = null; state.done = null; return paint(); }
218
+ const rb = t.closest('[data-rollback]');
219
+ if (rb) { state.pending = { action: 'rollback', deployId: rb.getAttribute('data-rollback'), liveWas: state.hist.liveDeployId }; state.error = null; state.done = null; return paint(); }
220
+ if (t.closest('#rd-cancel')) { state.pending = null; return paint(); }
221
+ if (t.closest('#rd-confirm')) return confirm();
222
+ const v = t.closest('[data-view]');
223
+ if (v) { state.viewing = v.getAttribute('data-view'); return paint(); }
224
+ if (t.closest('#rd-unview')) { state.viewing = null; return paint(); }
225
+ });
226
+
227
+ function loadSheet() {
228
+ if (document.getElementById('rd-css') || !document.createElement) return;
229
+ const l = document.createElement('link');
230
+ l.id = 'rd-css'; l.rel = 'stylesheet'; l.href = '/modules/render-deploy/public/deploy.css';
231
+ document.head.appendChild(l);
232
+ }
233
+
234
+ async function start() {
235
+ if (!mount()) return;
236
+ loadSheet();
237
+ paint();
238
+ try {
239
+ const d = await call('GET', '/render-deploy/app');
240
+ state.app = d.app;
241
+ } catch (e) {
242
+ // Not this reader's to see, or the module is off: show nothing, like the npm page.
243
+ $('rd-panel').hidden = true; return;
244
+ }
245
+ paint();
246
+ }
247
+
248
+ start();
249
+ })();
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ // modules/render-deploy/render.js — how a request reaches Render (task 1004270).
4
+ //
5
+ // THE KEY. The owner's Render key is account-wide and is never stored (ADR 0327 §2). So it
6
+ // rides each request in the `x-render-key` header (a header, not a body: the kernel's audit
7
+ // log keeps a copy of every write body, and no header), is shape-checked, handed to the ONE
8
+ // Render client for that request, and dropped when the request ends. Nothing here logs it,
9
+ // echoes it, or writes it anywhere. An error from Render is turned into a sentence that names
10
+ // what happened, never the key or the request that carried it.
11
+ //
12
+ // What that costs, said plainly: the key passes through this hall's web tier in plaintext for
13
+ // the length of the request (ADR 0327 §2 records the same of the standup). The owner types it
14
+ // on the page each time they read from or act on Render.
15
+ //
16
+ // THE CLIENT. scripts/gds/render-api.js, unchanged in kind: built for one workspace, asserts
17
+ // the workspace on every answer, and has no delete. This file only builds it.
18
+
19
+ const { KEY_RE } = require('./deploys');
20
+
21
+ function defaultMakeClient({ key, ownerId }) {
22
+ // Lazy, so a hall that never reaches Render never loads the client.
23
+ const { RenderApi } = require('../../scripts/gds/render-api');
24
+ return new RenderApi({ token: key, ownerId, dryRun: false });
25
+ }
26
+
27
+ const refusal = (res, status, code, message) => res.status(status).json({ error: { code, message } });
28
+
29
+ /** The key off the request, or null. Never returned to the caller in any response. */
30
+ function keyFrom(req) {
31
+ const raw = req.headers && req.headers['x-render-key'];
32
+ const key = typeof raw === 'string' ? raw.trim() : '';
33
+ return KEY_RE.test(key) ? key : null;
34
+ }
35
+
36
+ const NEEDS_KEY = 'Enter your Render API key. It is used for this one request, then dropped. It is never saved.';
37
+
38
+ /**
39
+ * A Render failure as a response. A 401 from Render is answered 422 here, never 401: to the
40
+ * hall's client a 401 means "your Bongos session ended" and sends the browser to sign in.
41
+ */
42
+ function renderFailure(res, e, log) {
43
+ const name = e && e.name;
44
+ if (log) log.error(`render call failed: ${name || 'error'} ${e && e.status ? e.status : ''}`.trim());
45
+ if (name === 'RenderOwnerMismatch') {
46
+ return refusal(res, 403, 'wrong_workspace', 'Render answered from a different workspace than the one this project is connected to, so nothing was done.');
47
+ }
48
+ const status = e && e.status;
49
+ if (status === 401 || status === 403) {
50
+ return refusal(res, 422, 'render_refused_key', 'Render did not accept that key for this service. Check the key, and that it belongs to a user in the connected workspace.');
51
+ }
52
+ if (status === 404) return refusal(res, 404, 'render_not_found', 'Render has no such service or deploy in the connected workspace.');
53
+ return refusal(res, 502, 'render_unreachable', 'Render did not answer that request. Nothing was changed. Try again in a moment.');
54
+ }
55
+
56
+ module.exports = { defaultMakeClient, keyFrom, refusal, renderFailure, NEEDS_KEY };