@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
@@ -82,11 +82,113 @@ const MAX_ALT_LENGTH = 300; // the caption/alt text stored beside it.
82
82
  // ever opens a name that matches it.
83
83
  const NAME_RE = /^task-(\d{1,12})-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
84
84
 
85
- // Where visuals live. Outside the git tree (a `var/` dir) so a deploy's
86
- // `git reset --hard` / pull never wipes them; gitignored. Env override for tests.
85
+ // Where visuals live: <INSTANCE ROOT>/var/task-visuals (task 1004381).
86
+ //
87
+ // THE BUG THIS FIXES. This used to be path.join(__dirname, '..', '..', 'var',
88
+ // 'task-visuals') — the CORE root. Correct in a single checkout, where core root
89
+ // and instance root are one directory. Wrong the day a site runs the consumer
90
+ // layout: __dirname is then node_modules/@bongos/core/modules/lifecycle, so every
91
+ // visual was written INSIDE the installed package, and every core upgrade (npm ci
92
+ // replaces the package) silently erased them all. Seen live on cloudbongos.com:
93
+ // eight page-tweak renders attached on 1.19.1076 were 404 five upgrades later.
94
+ // The same failure the doorway's resolveInstanceRoot note records for
95
+ // modules/government; the discord bug-attachments store (the sibling this lib
96
+ // follows) was already moved for it.
97
+ //
98
+ // TASK_VISUALS_DIR stays the override (tests, or an operator who wants the
99
+ // images on another disk). The directory is created on first write, by the
100
+ // server process's own user, with a `*` .gitignore inside it: the instance root
101
+ // is a git checkout whose tree `bongos upgrade` requires to be CLEAN, and an
102
+ // instance's scaffolded .gitignore does not list var/. See ensureVisualsDir.
103
+ let instanceRootCache;
104
+ function instanceRoot() {
105
+ if (instanceRootCache === undefined) {
106
+ try {
107
+ // Through the published doorway (ADR 0083), lazily: the CLI requires this
108
+ // lib for readImageFile and must not pay for the doorway.
109
+ instanceRootCache = require('../../src/module-api').resolveInstanceRoot();
110
+ } catch (e) {
111
+ // A storage-path lookup must never take the server down: fall back to the
112
+ // historical location, which is right in a single checkout.
113
+ instanceRootCache = path.join(__dirname, '..', '..');
114
+ }
115
+ }
116
+ return instanceRootCache;
117
+ }
118
+
119
+ // The old, in-package location. Read only by migrateLegacyVisuals, so a site
120
+ // that wrote visuals there before this fix keeps them.
121
+ function legacyVisualsDir() {
122
+ return path.join(__dirname, '..', '..', 'var', 'task-visuals');
123
+ }
124
+
125
+ // PURE given its inputs — the store directory for an instance root. Split out so
126
+ // the consumer-layout test can prove where it lands without faking a process.
127
+ function visualsDirFor({ env = process.env, root } = {}) {
128
+ if (env.TASK_VISUALS_DIR) return env.TASK_VISUALS_DIR;
129
+ return path.join(root || instanceRoot(), 'var', 'task-visuals');
130
+ }
131
+
87
132
  function visualsDir() {
88
- return process.env.TASK_VISUALS_DIR
89
- || path.join(__dirname, '..', '..', 'var', 'task-visuals');
133
+ return visualsDirFor();
134
+ }
135
+
136
+ // mkdir -p the store and, for the DEFAULT store under the instance root, drop a
137
+ // `*` .gitignore in it, so the files never show as untracked in the instance's
138
+ // git tree (an untracked path there halts the next `bongos upgrade` pre-flight).
139
+ // A TASK_VISUALS_DIR override is the operator's own directory and is left bare.
140
+ // Idempotent; the .gitignore write is best-effort.
141
+ const DIR_GITIGNORE = '# Task visuals (task 1004381): runtime data, never committed.\n*\n';
142
+ async function ensureVisualsDir(dir, { env = process.env } = {}) {
143
+ await fs.mkdir(dir, { recursive: true });
144
+ if (env.TASK_VISUALS_DIR) return;
145
+ try {
146
+ await fs.writeFile(path.join(dir, '.gitignore'), DIR_GITIGNORE, { flag: 'wx' });
147
+ } catch (err) { /* EEXIST is the steady state; anything else is non-fatal */ }
148
+ }
149
+
150
+ // One-time boot move: any stored visual still at the old in-package path is
151
+ // moved into the new store, so a site that attached renders before upgrading to
152
+ // this fix does not lose them on the upgrade after. A no-op when the two paths
153
+ // are the same directory (a single checkout) or the old one is absent. Never
154
+ // overwrites a file already in the new store, never throws: it returns
155
+ // { moved, skipped, failed } for the caller to log.
156
+ //
157
+ // With TASK_VISUALS_DIR set (and no explicit `from`) there is nothing to move:
158
+ // the old code honoured the same override, so no visual was ever written to the
159
+ // legacy path. That also keeps a test that points the store at a temp dir from
160
+ // sweeping a real checkout's var/task-visuals into it.
161
+ async function migrateLegacyVisuals({ from, to = visualsDir(), env = process.env } = {}) {
162
+ const out = { moved: 0, skipped: 0, failed: 0 };
163
+ if (from === undefined) {
164
+ if (env.TASK_VISUALS_DIR) return out;
165
+ from = legacyVisualsDir();
166
+ }
167
+ if (path.resolve(from) === path.resolve(to)) return out;
168
+ let names;
169
+ try { names = await fs.readdir(from); } catch (err) { return out; }
170
+ names = names.filter(isSafeName);
171
+ if (!names.length) return out;
172
+ await ensureVisualsDir(to);
173
+ for (const name of names) {
174
+ const src = path.join(from, name);
175
+ const dest = path.join(to, name);
176
+ try {
177
+ try { await fs.access(dest); out.skipped += 1; continue; } catch (e) { /* absent: move it */ }
178
+ try {
179
+ await fs.rename(src, dest);
180
+ } catch (err) {
181
+ if (!err || err.code !== 'EXDEV') throw err;
182
+ // Another filesystem: copy, then remove the original.
183
+ await fs.copyFile(src, dest, fsSync.constants.COPYFILE_EXCL);
184
+ await fs.unlink(src);
185
+ }
186
+ out.moved += 1;
187
+ } catch (err) {
188
+ out.failed += 1;
189
+ }
190
+ }
191
+ return out;
90
192
  }
91
193
 
92
194
  // PURE — is this a well-formed, server-generated visual name?
@@ -242,7 +344,7 @@ async function persistImageBuffer(buf, { taskId, contentType, dir = visualsDir()
242
344
  const screened = screenImage(buf, contentType);
243
345
  if (!screened.ok) return screened;
244
346
  const file = `task-${id}-${crypto.randomBytes(8).toString('hex')}.${screened.ext}`;
245
- await fs.mkdir(dir, { recursive: true });
347
+ await ensureVisualsDir(dir);
246
348
  await fs.writeFile(path.join(dir, file), buf, { mode: 0o640 });
247
349
  return { ok: true, file, url: urlForName(file), bytes: buf.length };
248
350
  }
@@ -278,6 +380,8 @@ module.exports = {
278
380
  MAX_BYTES,
279
381
  MAX_ALT_LENGTH,
280
382
  visualsDir,
383
+ visualsDirFor,
384
+ migrateLegacyVisuals,
281
385
  isSafeName,
282
386
  taskIdFromName,
283
387
  resolveSafe,
@@ -15,7 +15,8 @@
15
15
  "contributes": {
16
16
  "routes": [
17
17
  "work",
18
- "task-where"
18
+ "task-where",
19
+ "preview"
19
20
  ],
20
21
  "uiSections": [
21
22
  "task-where"
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/preview/commands.js — the shell commands a preview start runs, as data
4
+ // (task 1004300). Each is `{ cmd, args, env?, cwd? }` for spawn WITHOUT a shell, so a version
5
+ // string can never be parsed as anything but an argument. The supervisor runs them in order.
6
+ //
7
+ // THE COPY IS A DUMP AND A RESTORE, never a template copy. A template copy needs the source
8
+ // database to have no other connections, and the live hall holds a pool open on it for as
9
+ // long as it runs — the copy would fail, or worse, force the live pool off. pg_dump reads
10
+ // under a snapshot and takes no lock the live hall notices.
11
+
12
+ const path = require('node:path');
13
+ const { PREVIEW_DB } = require('./env');
14
+
15
+ const PACKAGE = '@bongos/core';
16
+ // The strict shape of a published version. Anything else never reaches a command line.
17
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
18
+
19
+ function assertVersion(version) {
20
+ if (!VERSION_RE.test(String(version))) throw new TypeError(`not a version: ${JSON.stringify(version)}`);
21
+ }
22
+
23
+ /** Where a version's install lives: one directory per version, under the cache root. */
24
+ function versionDir(cacheRoot, version) {
25
+ assertVersion(version);
26
+ return path.join(cacheRoot, version);
27
+ }
28
+
29
+ /** The installed core's server entry — what the preview process runs. */
30
+ function entryPath(cacheRoot, version) {
31
+ return path.join(versionDir(cacheRoot, version), 'node_modules', '@bongos', 'core', 'src', 'platform-server.js');
32
+ }
33
+
34
+ /** Install one published core version into its own directory. Production dependencies only. */
35
+ function installCommand({ cacheRoot, version }) {
36
+ return { cmd: 'npm', args: ['install', '--prefix', versionDir(cacheRoot, version), `${PACKAGE}@${version}`, '--omit=dev'] };
37
+ }
38
+
39
+ /** Drop the copy if one is left over — used before a start and on every stop. */
40
+ function dropDbCommand() {
41
+ return { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] };
42
+ }
43
+
44
+ /** An empty database for the copy. Deliberately no template (see the header). */
45
+ function createDbCommand() {
46
+ return { cmd: 'createdb', args: [PREVIEW_DB] };
47
+ }
48
+
49
+ /** Snapshot the live database to a custom-format dump file. */
50
+ function dumpCommand({ liveDb, dumpFile }) {
51
+ if (!liveDb) throw new TypeError('dumpCommand: the live database name is required');
52
+ if (liveDb === PREVIEW_DB) throw new Error('refusing to preview from the preview database itself');
53
+ return { cmd: 'pg_dump', args: ['-Fc', '-f', dumpFile, liveDb] };
54
+ }
55
+
56
+ /** Load the dump into the copy. --no-owner so it needs no role the copy does not have. */
57
+ function restoreCommand({ dumpFile }) {
58
+ return { cmd: 'pg_restore', args: ['--no-owner', '-d', PREVIEW_DB, dumpFile] };
59
+ }
60
+
61
+ /**
62
+ * Run the INSTALLED version's migrations against the copy — the point of a preview: a
63
+ * version's migration meets real data here, before it meets the live hall. INIT_CWD names
64
+ * the install directory as the instance root (migrate.sh resolves it from there);
65
+ * DATABASE_URL is blanked because the pool prefers it to PGDATABASE, and PGDATABASE is
66
+ * pinned to the copy so nothing can be applied to the live database by mistake.
67
+ */
68
+ function migrateCommand({ cacheRoot, version, env }) {
69
+ const dir = versionDir(cacheRoot, version);
70
+ return {
71
+ cmd: 'bash',
72
+ args: [path.join(dir, 'node_modules', '@bongos', 'core', 'scripts', 'migrate.sh')],
73
+ cwd: dir,
74
+ env: { ...env, INIT_CWD: dir, PGDATABASE: PREVIEW_DB, DATABASE_URL: '' },
75
+ };
76
+ }
77
+
78
+ module.exports = {
79
+ VERSION_RE, PACKAGE, versionDir, entryPath,
80
+ installCommand, dropDbCommand, createDbCommand, dumpCommand, restoreCommand, migrateCommand,
81
+ };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/preview/divert.js — the provider for the kernel's optional
4
+ // request.divert seam: it decides, per request, whether a request belongs to the live hall
5
+ // or to the running version preview (task 1004300, ADR 0349).
6
+ //
7
+ // A browser is steered by a cookie, bongos_preview=<version>, set when its owner opens the
8
+ // preview. Same origin, so sign-in and cookies just work and no second OAuth app is needed.
9
+ // A request is forwarded only when EVERY one of these holds; otherwise it falls through to
10
+ // the live hall untouched:
11
+ // - the cookie names a version, and that is the version now running
12
+ // - the path is not sign-in (/api/bongos/auth/*) and not this module's own preview routes,
13
+ // so signing in, and getting OUT of a preview, always reach the live hall
14
+ // - the request is not a CLI/API call (an Authorization header): those are never previews
15
+ // - the caller is signed in AND holds core.pin.move — checked on EVERY request, uncached,
16
+ // with the same gates a route would use, so a revoked permission ends the preview for
17
+ // that person on their next click
18
+ // A failed permission check also clears the cookie, so nobody is left stranded in a loop.
19
+
20
+ const { VERSION_RE } = require('./commands');
21
+ const { COOKIE_NAME } = require('./proxy');
22
+
23
+ // The API is served under every prefix the kernel mounts it at: /api/bongos, the /api/bongos/v1
24
+ // versioned form, and the legacy /api/gds. An exclusion that named only one would let the same
25
+ // sign-in or exit request through by another spelling, so each pattern allows the optional
26
+ // version segment (src/bongos/api-prefix.js owns the list; a module cannot import it).
27
+ const AUTH_PATH = /^\/api\/(?:bongos|gds)(?:\/v\d+)?\/auth(?:\/|$)/;
28
+ const PREVIEW_PATH = /^\/api\/(?:bongos|gds)(?:\/v\d+)?\/npm-release\/preview(?:\/|$)/;
29
+
30
+ function cookieVersion(header) {
31
+ for (const part of String(header || '').split(';')) {
32
+ const s = part.trim();
33
+ if (s.startsWith(`${COOKIE_NAME}=`)) {
34
+ const v = s.slice(COOKIE_NAME.length + 1);
35
+ return VERSION_RE.test(v) ? v : null;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function pathOf(req) {
42
+ const url = String(req.originalUrl || req.url || '');
43
+ const q = url.indexOf('?');
44
+ return q === -1 ? url : url.slice(0, q);
45
+ }
46
+
47
+ /** The Set-Cookie value that removes the steering cookie. */
48
+ function clearCookie() {
49
+ return `${COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`;
50
+ }
51
+
52
+ // Run an express-style gate to its verdict without letting it write a response of its own:
53
+ // the gates answer a refusal through res.fail, which does not exist this early in the
54
+ // request, and a refusal here means "not yours to preview", not an error page.
55
+ function passes(gate, req) {
56
+ return new Promise((resolve) => {
57
+ let settled = false;
58
+ const done = (ok) => { if (!settled) { settled = true; resolve(ok); } };
59
+ const res = { fail: () => done(false) };
60
+ Promise.resolve(gate(req, res, (err) => done(!err))).catch(() => done(false));
61
+ });
62
+ }
63
+
64
+ /**
65
+ * @param {object} o
66
+ * @param {object} o.supervisor
67
+ * @param {Function} o.forward (req, res) => void — the proxy
68
+ * @param {Function} o.requireBuilder the kernel sign-in gate
69
+ * @param {Function} o.requirePermission the kernel permission gate factory
70
+ * @returns {(req, res, next) => Promise<void>}
71
+ */
72
+ function createDivert({ supervisor, forward, requireBuilder, requirePermission }) {
73
+ const mayPreview = requirePermission('core.pin.move');
74
+ return async function divert(req, res, next) {
75
+ const wanted = cookieVersion(req.headers.cookie);
76
+ if (!wanted) return next();
77
+ const st = supervisor.status();
78
+ if (st.state !== 'running' || st.version !== wanted) {
79
+ // The preview this cookie pointed at is gone: drop the cookie and serve the live hall.
80
+ res.append('Set-Cookie', clearCookie());
81
+ return next();
82
+ }
83
+ const p = pathOf(req);
84
+ if (AUTH_PATH.test(p) || PREVIEW_PATH.test(p)) return next();
85
+ if (req.headers.authorization) return next();
86
+ if (!(await passes(requireBuilder, req)) || !(await passes(mayPreview, req))) {
87
+ res.append('Set-Cookie', clearCookie());
88
+ return next();
89
+ }
90
+ supervisor.touch();
91
+ return forward(req, res);
92
+ };
93
+ }
94
+
95
+ module.exports = { createDivert, cookieVersion, clearCookie, AUTH_PATH, PREVIEW_PATH };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/preview/env.js — the environment a version preview runs in (task 1004300).
4
+ //
5
+ // A preview is another Bongos process on the same droplet, running an OLDER (or newer)
6
+ // core against a COPY of the live database. Anything that process can reach, it can act
7
+ // on: a Discord webhook, a GitHub token, an AI key, the cron pollers that post, pay and
8
+ // email. So its environment is built FROM NOTHING — an allow-list of the few names a
9
+ // process needs to boot and reach Postgres — and never as a copy of ours with a few
10
+ // names deleted. A deny-list forgets the next secret someone adds; an allow-list cannot.
11
+ //
12
+ // The pollers and the modules that talk outward are then switched off by name, and HOME
13
+ // is pointed at an empty scratch directory so a config file in the real home (the
14
+ // Discord webhook file among them) is not found.
15
+
16
+ // The only names carried over from the live process.
17
+ // PATH so node, and the child's own tooling, resolve
18
+ // NODE_ENV production behaviour, as the live hall runs it
19
+ // PGHOST/PGPORT which Postgres server (the copy lives on the same one)
20
+ // PGUSER/PGPASSWORD the role that connects — needed to open a pool at all
21
+ // PORT and HOST are NOT carried: the preview binds its own, set below.
22
+ const CARRIED = Object.freeze(['PATH', 'NODE_ENV', 'PGHOST', 'PGPORT', 'PGUSER', 'PGPASSWORD']);
23
+
24
+ // The name of the copy database. Fixed, so a leftover from a crash is dropped by the next
25
+ // start instead of piling up.
26
+ const PREVIEW_DB = 'bongos_preview';
27
+
28
+ // Every background poller the platform has, off. Each is read by name in modules/ or
29
+ // src/platform-server.js; a poller that is not listed here would keep running in the copy.
30
+ const POLLERS_OFF = Object.freeze([
31
+ 'PUBLISH_RECONCILER_DISABLED',
32
+ 'ROUTINE_TIMERS_DISABLED',
33
+ 'BOARD_EXPIRY_DISABLED',
34
+ 'SEARCH_INDEX_SWEEP_DISABLED',
35
+ 'SESSION_UPLOAD_HEALTH_DISABLED',
36
+ 'SESSION_REWARD_RECONCILE_DISABLED',
37
+ 'PROVISIONING_LIVENESS_DISABLED',
38
+ 'PROVISIONING_CATALOG_BACKFILL_DISABLED',
39
+ 'CONFLICT_RESOLVE_DISABLED',
40
+ ]);
41
+
42
+ // Modules that talk to the outside world, off. npm-release is off too: a preview must not
43
+ // offer previews of its own.
44
+ const MODULES_OFF = Object.freeze([
45
+ 'BONGOS_MODULE_DISCORD',
46
+ 'BONGOS_MODULE_AGENTS',
47
+ 'BONGOS_MODULE_PROVISIONING',
48
+ 'BONGOS_MODULE_NPM_RELEASE',
49
+ ]);
50
+
51
+ /**
52
+ * Build the child's environment.
53
+ *
54
+ * @param {object} o
55
+ * @param {object} [o.env] the live process's environment (only CARRIED names are read)
56
+ * @param {number|string} o.port the loopback port the preview listens on
57
+ * @param {string} o.scratchHome an empty directory to serve as HOME
58
+ * @returns {Record<string,string>} a fresh object, never `env` itself
59
+ */
60
+ function buildPreviewEnv({ env = process.env, port, scratchHome } = {}) {
61
+ if (!port) throw new TypeError('buildPreviewEnv: port is required');
62
+ if (!scratchHome) throw new TypeError('buildPreviewEnv: scratchHome is required');
63
+ const out = {};
64
+ for (const name of CARRIED) {
65
+ if (env[name] !== undefined && env[name] !== '') out[name] = String(env[name]);
66
+ }
67
+ if (!out.NODE_ENV) out.NODE_ENV = 'production';
68
+ out.PORT = String(port);
69
+ out.HOST = '127.0.0.1';
70
+ out.HOME = scratchHome;
71
+ out.PGDATABASE = PREVIEW_DB;
72
+ out.CHAT_DRY_RUN = '1';
73
+ for (const name of POLLERS_OFF) out[name] = '1';
74
+ for (const name of MODULES_OFF) out[name] = '0';
75
+ return out;
76
+ }
77
+
78
+ module.exports = { buildPreviewEnv, PREVIEW_DB, CARRIED, POLLERS_OFF, MODULES_OFF };
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/preview/proxy.js — forwards one request to the running preview and
4
+ // stamps every page that comes back with the "You are viewing Bongos X" banner
5
+ // (task 1004300, ADR 0349).
6
+ //
7
+ // TRANSPARENT ON PURPOSE. The Host header is kept (the hall is routed on it), the method,
8
+ // path, query and body go through as they came, and a response that is not a page — a JSON
9
+ // answer, a script, an event stream — is piped straight back without being read, so a
10
+ // server-sent-events connection stays open and unbuffered. Only a text/html answer is held
11
+ // long enough to place the banner after its opening body tag; the preview is asked for an
12
+ // uncompressed body so there is nothing to inflate. A version whose pages are older than
13
+ // any banner code still shows it, because the banner is added HERE, never by the preview.
14
+
15
+ const http = require('node:http');
16
+
17
+ // Hop-by-hop headers are meant for one connection, not for the next.
18
+ const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
19
+
20
+ const COOKIE_NAME = 'bongos_preview';
21
+
22
+ // The cookie that steers a browser into the preview is ours and stays with us: the preview
23
+ // has no use for it, and a preview page must not be able to read what routed it there.
24
+ function withoutPreviewCookie(cookieHeader) {
25
+ return String(cookieHeader || '')
26
+ .split(';')
27
+ .map((s) => s.trim())
28
+ .filter((s) => s && !s.startsWith(`${COOKIE_NAME}=`))
29
+ .join('; ');
30
+ }
31
+
32
+ function escapeHtml(s) {
33
+ return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
34
+ }
35
+
36
+ /** The banner markup: which version this is, which one the hall runs, and the way out. */
37
+ function bannerHtml({ viewing, running, exitHref }) {
38
+ return '<div id="bongos-preview-banner" role="status" style="position:sticky;top:0;z-index:2147483647;'
39
+ + 'display:flex;gap:12px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 16px;'
40
+ + 'background:#1f2933;color:#fff;font:14px/1.4 system-ui,sans-serif;">'
41
+ + `<span>You are viewing Bongos ${escapeHtml(viewing)} &mdash; this hall runs ${escapeHtml(running)}. Changes here do not reach the live hall.</span>`
42
+ + `<a href="${escapeHtml(exitHref)}" style="color:#fff;text-decoration:underline;font-weight:600;">Back to the live hall</a>`
43
+ + '</div>';
44
+ }
45
+
46
+ /** Place the banner after the opening body tag. A document with no body tag is left alone. */
47
+ function injectBanner(html, banner) {
48
+ const m = /<body\b[^>]*>/i.exec(html);
49
+ if (!m) return html;
50
+ const at = m.index + m[0].length;
51
+ return html.slice(0, at) + banner + html.slice(at);
52
+ }
53
+
54
+ /**
55
+ * @param {object} o
56
+ * @param {()=>number} o.port the preview's loopback port
57
+ * @param {()=>{viewing:string,running:string}} o.banner what the banner should say now
58
+ * @param {string} o.exitHref
59
+ * @param {object} [o.httpImpl] node:http, swapped in tests
60
+ * @returns {(req, res) => void}
61
+ */
62
+ function createProxy({ port, banner, exitHref, httpImpl = http } = {}) {
63
+ return function forward(req, res) {
64
+ const headers = {};
65
+ for (const [k, v] of Object.entries(req.headers)) {
66
+ if (HOP.has(k) || k === 'accept-encoding') continue;
67
+ headers[k] = v;
68
+ }
69
+ headers['accept-encoding'] = 'identity';
70
+ const cookie = withoutPreviewCookie(req.headers.cookie);
71
+ if (cookie) headers.cookie = cookie; else delete headers.cookie;
72
+
73
+ const up = httpImpl.request(
74
+ { host: '127.0.0.1', port: port(), method: req.method, path: req.originalUrl || req.url, headers },
75
+ (upRes) => {
76
+ const out = {};
77
+ for (const [k, v] of Object.entries(upRes.headers)) if (!HOP.has(k)) out[k] = v;
78
+ const isPage = /^text\/html\b/i.test(String(upRes.headers['content-type'] || ''));
79
+ if (!isPage) {
80
+ res.writeHead(upRes.statusCode, out);
81
+ upRes.pipe(res);
82
+ return;
83
+ }
84
+ const chunks = [];
85
+ upRes.on('data', (c) => chunks.push(c));
86
+ upRes.on('end', () => {
87
+ const html = injectBanner(Buffer.concat(chunks).toString('utf8'), bannerHtml({ ...banner(), exitHref }));
88
+ delete out['content-length'];
89
+ delete out.etag;
90
+ out['cache-control'] = 'no-store';
91
+ res.writeHead(upRes.statusCode, out);
92
+ res.end(html);
93
+ });
94
+ upRes.on('error', () => res.destroy());
95
+ },
96
+ );
97
+ up.on('error', () => {
98
+ if (res.headersSent) { res.destroy(); return; }
99
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
100
+ res.end('The preview is not answering. Go back to the live hall and start it again.');
101
+ });
102
+ res.on('close', () => { if (!res.writableFinished) up.destroy(); });
103
+ req.pipe(up);
104
+ };
105
+ }
106
+
107
+ module.exports = { createProxy, injectBanner, bannerHtml, withoutPreviewCookie, COOKIE_NAME };
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/preview/runtime.js — the real-world wiring of the preview supervisor
4
+ // (task 1004300, ADR 0349): actual child processes, an actual health probe, an idle timer.
5
+ // Everything the supervisor takes as a function is bound here to the operating system, so
6
+ // supervisor.js itself stays free of it and the tests never need to come near this file.
7
+ //
8
+ // NOT EXERCISED BY THE UNIT TESTS, by design: it spawns npm, pg_dump and node. It is small
9
+ // enough to read whole, and the droplet run recorded in the task's part B is what proves it.
10
+
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+ const http = require('node:http');
14
+ const { spawn } = require('node:child_process');
15
+ const { createSupervisor, DEFAULT_PORT } = require('./supervisor');
16
+
17
+ const REAP_EVERY_MS = 60 * 1000;
18
+
19
+ // The only programs a preview may run, by the name commands.js gives them. Each entry
20
+ // spawns a LITERAL program name, so what runs is answerable by reading this table (the
21
+ // exec-path guard, task 1003371, requires exactly that); a command not in it is refused.
22
+ const PROGRAMS = Object.freeze({
23
+ npm: (args, o) => spawn('npm', args, o),
24
+ dropdb: (args, o) => spawn('dropdb', args, o),
25
+ createdb: (args, o) => spawn('createdb', args, o),
26
+ pg_dump: (args, o) => spawn('pg_dump', args, o),
27
+ pg_restore: (args, o) => spawn('pg_restore', args, o),
28
+ bash: (args, o) => spawn('bash', args, o),
29
+ });
30
+
31
+ /** Run one command to the end, keeping the tail of its error output for the failure message. */
32
+ function runCommand({ cmd, args, env, cwd }) {
33
+ return new Promise((resolve) => {
34
+ let stderr = '';
35
+ let child;
36
+ if (!Object.prototype.hasOwnProperty.call(PROGRAMS, cmd)) {
37
+ resolve({ code: -1, stderr: 'not a program a preview may run: ' + cmd });
38
+ return;
39
+ }
40
+ try {
41
+ child = PROGRAMS[cmd](args, { env: env || process.env, cwd, stdio: ['ignore', 'ignore', 'pipe'] });
42
+ } catch (e) {
43
+ resolve({ code: -1, stderr: e.message });
44
+ return;
45
+ }
46
+ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
47
+ child.on('error', (e) => resolve({ code: -1, stderr: e.message }));
48
+ child.on('close', (code) => resolve({ code: code === null ? -1 : code, stderr }));
49
+ });
50
+ }
51
+
52
+ /** Does something answer /healthz on the loopback port? */
53
+ function probeHealth(port) {
54
+ return new Promise((resolve) => {
55
+ const req = http.get({ host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, (res) => {
56
+ res.resume();
57
+ resolve(res.statusCode === 200);
58
+ });
59
+ req.on('error', () => resolve(false));
60
+ req.on('timeout', () => { req.destroy(); resolve(false); });
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Build the production supervisor: the port from NPM_RELEASE_PREVIEW_PORT (default 3190,
66
+ * loopback only), installs cached under ~/.cache/bongos-preview, and a timer that stops
67
+ * an idle preview and a hook that stops it when this process does.
68
+ */
69
+ function createRuntime({ liveDb, log, env = process.env } = {}) {
70
+ const port = Number(env.NPM_RELEASE_PREVIEW_PORT) || DEFAULT_PORT;
71
+ const supervisor = createSupervisor({
72
+ run: runCommand, spawn, probe: probeHealth, liveDb, log, port, env,
73
+ cacheRoot: path.join(os.homedir(), '.cache', 'bongos-preview'),
74
+ });
75
+ const timer = setInterval(() => {
76
+ supervisor.reapIdle().catch((e) => log.warn(`preview idle check failed: ${e && e.message}`));
77
+ }, REAP_EVERY_MS);
78
+ timer.unref();
79
+ process.once('exit', () => { supervisor.stop().catch(() => {}); });
80
+ return supervisor;
81
+ }
82
+
83
+ module.exports = { createRuntime, runCommand, probeHealth };