@bongos/core 1.19.1081 → 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 (251) hide show
  1. package/.bongos-core.json +526 -191
  2. package/.claude/skills/planning-session/SKILL.md +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +32 -0
  5. package/clients/bongos-client/index.cjs +32 -0
  6. package/clients/bongos-client/index.d.ts +51 -6
  7. package/clients/bongos-client/index.mjs +32 -0
  8. package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
  9. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  10. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
  11. package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
  12. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  13. package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  14. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  15. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  16. package/docs/adr/README.md +3 -0
  17. package/docs/api/openapi.json +851 -19
  18. package/docs/api-reference.md +33 -9
  19. package/docs/architecture.md +6 -2
  20. package/docs/copy-inventory.md +620 -553
  21. package/docs/copy-registry.json +1467 -820
  22. package/docs/file-map.md +2 -0
  23. package/docs/module-api-changelog.md +6 -0
  24. package/docs/modules-contract.md +1 -0
  25. package/docs/page-inventory.json +38 -4
  26. package/docs/page-readings.json +1674 -1566
  27. package/migrations/core_260_goals_working_area.sql +63 -0
  28. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  29. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  30. package/modules/agents/lib/validate.js +43 -0
  31. package/modules/autonomy/gauge.js +38 -2
  32. package/modules/grading/grader-subagent.js +37 -2
  33. package/modules/grading/grader-workers/reader.js +65 -5
  34. package/modules/hall-ui/public/approval-queue.css +33 -7
  35. package/modules/hall-ui/public/approval-queue.js +9 -2
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/brand-holes.js +121 -0
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/collab.js +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +6 -1
  44. package/modules/hall-ui/public/deploy.js +22 -8
  45. package/modules/hall-ui/public/diagrams.html +1 -1
  46. package/modules/hall-ui/public/dom-utils.js +20 -0
  47. package/modules/hall-ui/public/drachmae.html +1 -1
  48. package/modules/hall-ui/public/fleet.html +1 -1
  49. package/modules/hall-ui/public/gate.html +1 -1
  50. package/modules/hall-ui/public/goals.html +1 -1
  51. package/modules/hall-ui/public/government.html +1 -1
  52. package/modules/hall-ui/public/idea.html +1 -1
  53. package/modules/hall-ui/public/idea.js +28 -0
  54. package/modules/hall-ui/public/ideas.html +1 -1
  55. package/modules/hall-ui/public/ideas.js +19 -3
  56. package/modules/hall-ui/public/index.html +5 -1
  57. package/modules/hall-ui/public/modules.html +1 -1
  58. package/modules/hall-ui/public/primer.html +1 -1
  59. package/modules/hall-ui/public/profile-nudge.js +21 -4
  60. package/modules/hall-ui/public/profile.html +1 -1
  61. package/modules/hall-ui/public/profile.js +1 -1
  62. package/modules/hall-ui/public/project-settings.html +1 -1
  63. package/modules/hall-ui/public/ranks.html +1 -1
  64. package/modules/hall-ui/public/roadmap.html +1 -1
  65. package/modules/hall-ui/public/roster.html +1 -1
  66. package/modules/hall-ui/public/sessions.html +1 -1
  67. package/modules/hall-ui/public/settings.html +19 -30
  68. package/modules/hall-ui/public/settings.js +24 -177
  69. package/modules/hall-ui/public/settings.states.json +1 -1
  70. package/modules/hall-ui/public/shell.js +4 -2
  71. package/modules/hall-ui/public/studio.css +14 -8
  72. package/modules/hall-ui/public/studio.html +5 -4
  73. package/modules/hall-ui/public/task.html +1 -1
  74. package/modules/hall-ui/public/thinking.css +6 -3
  75. package/modules/hall-ui/public/thinking.html +1 -1
  76. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  77. package/modules/hall-ui/public/tweak-editor.css +142 -11
  78. package/modules/hall-ui/public/tweak-editor.html +25 -9
  79. package/modules/hall-ui/public/tweak-editor.js +243 -10
  80. package/modules/hall-ui/public/watch.html +1 -1
  81. package/modules/hall-ui/public/work.html +1 -1
  82. package/modules/hall-ui/records/approval-queue.md +6 -0
  83. package/modules/hall-ui/records/tweak-editor.md +6 -0
  84. package/modules/ideas/ratify.js +36 -8
  85. package/modules/ideas/routes/ratify-goal.js +6 -0
  86. package/modules/lifecycle/db-analytics.js +79 -10
  87. package/modules/lifecycle/db-claims.js +48 -1
  88. package/modules/lifecycle/db-deps-criteria.js +94 -40
  89. package/modules/lifecycle/db-goals.js +4 -1
  90. package/modules/lifecycle/db-grade.js +9 -1
  91. package/modules/lifecycle/db-relevance-flags.js +183 -0
  92. package/modules/lifecycle/db-versions.js +4 -3
  93. package/modules/lifecycle/db.js +19 -0
  94. package/modules/lifecycle/done-when.js +5 -3
  95. package/modules/lifecycle/est-advisory.js +31 -4
  96. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  97. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  98. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  99. package/modules/lifecycle/module.json +2 -1
  100. package/modules/lifecycle/routes/claims.js +8 -1
  101. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  102. package/modules/lifecycle/routes/goals.js +1 -1
  103. package/modules/lifecycle/routes/tasks.js +27 -24
  104. package/modules/lifecycle/routes/visuals.js +9 -0
  105. package/modules/lifecycle/ship-preflight.js +7 -11
  106. package/modules/lifecycle/task-visuals.js +109 -5
  107. package/modules/npm-release/module.json +2 -1
  108. package/modules/npm-release/preview/commands.js +81 -0
  109. package/modules/npm-release/preview/divert.js +95 -0
  110. package/modules/npm-release/preview/env.js +78 -0
  111. package/modules/npm-release/preview/proxy.js +107 -0
  112. package/modules/npm-release/preview/runtime.js +83 -0
  113. package/modules/npm-release/preview/supervisor.js +175 -0
  114. package/modules/npm-release/public/work.js +65 -1
  115. package/modules/npm-release/routes/preview.js +133 -0
  116. package/modules/provisioning/app-status.js +83 -0
  117. package/modules/provisioning/module.json +2 -1
  118. package/modules/provisioning/pollers/app-liveness.js +110 -0
  119. package/modules/provisioning/render-lookup.js +68 -0
  120. package/modules/provisioning/render-standup.js +44 -1
  121. package/modules/provisioning/routes/render-standup.js +81 -6
  122. package/modules/provisioning/starter-bundles.js +4 -1
  123. package/modules/public-landing/public/account-private.states.json +14 -0
  124. package/modules/public-landing/public/account.html +513 -0
  125. package/modules/public-landing/public/account.probes.json +37 -0
  126. package/modules/public-landing/public/account.states.json +16 -0
  127. package/modules/public-landing/public/index.html +5 -2
  128. package/modules/public-landing/public/projects.html +661 -18
  129. package/modules/public-landing/public/projects.states.json +8 -1
  130. package/modules/render-deploy/deploys.js +203 -0
  131. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  132. package/modules/render-deploy/module.json +25 -0
  133. package/modules/render-deploy/public/deploy.css +10 -0
  134. package/modules/render-deploy/public/deploy.js +249 -0
  135. package/modules/render-deploy/render.js +56 -0
  136. package/modules/render-deploy/routes/act.js +88 -0
  137. package/modules/render-deploy/routes/app.js +65 -0
  138. package/modules/render-deploy/routes/history.js +38 -0
  139. package/modules/specialities/db.js +27 -8
  140. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  141. package/modules/specialities/routes/specialities.js +51 -3
  142. package/modules/specialities/skills.js +94 -0
  143. package/modules/specialities/specialities.js +56 -9
  144. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  145. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  146. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  147. package/modules/ui-design/kit/lib.js +3 -1
  148. package/modules/ui-design/kit/serve.js +96 -0
  149. package/package-lock.json +2 -2
  150. package/package.json +1 -1
  151. package/release-notes.json +163 -0
  152. package/scripts/gds/agents-sync.js +13 -3
  153. package/scripts/gds/autobongos-run.js +80 -2
  154. package/scripts/gds/autobongos-service.cmd +12 -0
  155. package/scripts/gds/copy-apply.js +14 -0
  156. package/scripts/gds/dev-box-guard.js +3 -2
  157. package/scripts/gds/fitness.js +9 -0
  158. package/scripts/gds/grade-correlation-audit.js +42 -12
  159. package/scripts/gds/grade-replay.js +1 -1
  160. package/scripts/gds/provision-core-upgrade.js +38 -7
  161. package/scripts/gds/provision-pin-key.js +176 -0
  162. package/scripts/gds/provision-render.js +62 -31
  163. package/scripts/gds/provision-teardown.js +7 -2
  164. package/scripts/gds/provision-units.js +31 -0
  165. package/scripts/gds/provision.js +10 -10
  166. package/scripts/gds/publish-manifest.js +1 -0
  167. package/scripts/gds/render-api.js +19 -0
  168. package/scripts/gds/render-payload.js +39 -65
  169. package/scripts/gds/run-unit-tests.js +10 -0
  170. package/scripts/gds/ship-finish.js +19 -20
  171. package/scripts/gds/smoke-dependencies.sh +37 -8
  172. package/scripts/gds/status.js +12 -4
  173. package/scripts/gds/upgrade.js +2 -2
  174. package/scripts/public-mirror-export.js +7 -1
  175. package/src/bongos/module-scope-map.js +13 -0
  176. package/src/bongos/serve-internal.js +3 -1
  177. package/src/module-api.js +10 -1
  178. package/src/platform-server.js +39 -0
  179. package/tests/account_privacy_flags.mjs +10 -6
  180. package/tests/agents_authoring.mjs +15 -1
  181. package/tests/agents_sync.mjs +106 -5
  182. package/tests/agents_validate.mjs +45 -0
  183. package/tests/api_path_404.mjs +6 -0
  184. package/tests/autobongos_cadence.mjs +7 -0
  185. package/tests/autobongos_loop.mjs +136 -1
  186. package/tests/autonomy_gauge.mjs +62 -0
  187. package/tests/blocker_hall_live_proof.mjs +275 -0
  188. package/tests/claim_gate_ci_unblock.mjs +116 -0
  189. package/tests/claim_gate_rebase.mjs +15 -3
  190. package/tests/collab_page.mjs +10 -0
  191. package/tests/conductor_main_e2e.mjs +97 -0
  192. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  193. package/tests/core_upgrade_runner.mjs +1 -0
  194. package/tests/dependency_writes_atomic.mjs +191 -0
  195. package/tests/deploy_page_projects.mjs +2 -1
  196. package/tests/effective_visibility_predicate.mjs +15 -0
  197. package/tests/est_advisory.mjs +27 -0
  198. package/tests/goal_map_page.mjs +26 -0
  199. package/tests/goal_task_relevance.mjs +518 -0
  200. package/tests/goal_working_area.mjs +146 -0
  201. package/tests/grade_attempts.mjs +61 -0
  202. package/tests/grade_attribution.mjs +23 -0
  203. package/tests/grade_correlation_audit.mjs +54 -1
  204. package/tests/grader_reader_lens.mjs +55 -2
  205. package/tests/grader_root_outage.mjs +6 -1
  206. package/tests/grader_subagent_tools_arg.mjs +102 -0
  207. package/tests/hall_approval_queue.mjs +54 -2
  208. package/tests/hall_settings_world.mjs +3 -1
  209. package/tests/hall_tweak_editor.mjs +316 -11
  210. package/tests/hub_account_page.mjs +394 -0
  211. package/tests/idea_detail_page.mjs +64 -0
  212. package/tests/idea_goal_ratification.mjs +64 -0
  213. package/tests/idea_spark_hall.mjs +60 -9
  214. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  215. package/tests/landing_page.mjs +6 -3
  216. package/tests/module-scope-map.mjs +31 -8
  217. package/tests/module_api.mjs +1 -0
  218. package/tests/module_loader.mjs +1 -1
  219. package/tests/nav_permission_atoms.mjs +1 -1
  220. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  221. package/tests/npm_release_preview_commands.mjs +89 -0
  222. package/tests/npm_release_preview_divert.mjs +164 -0
  223. package/tests/npm_release_preview_env.mjs +100 -0
  224. package/tests/npm_release_preview_proxy.mjs +162 -0
  225. package/tests/npm_release_preview_routes.mjs +208 -0
  226. package/tests/npm_release_preview_supervisor.mjs +203 -0
  227. package/tests/pin_write_key.mjs +383 -0
  228. package/tests/planning_session_skill.mjs +27 -0
  229. package/tests/platform_boot.mjs +71 -0
  230. package/tests/profile_nudge_links.mjs +86 -0
  231. package/tests/profile_ui_cross_project.mjs +1 -1
  232. package/tests/projects_hub.mjs +2 -2
  233. package/tests/projects_hub_app_status.mjs +249 -0
  234. package/tests/projects_hub_app_step.mjs +282 -52
  235. package/tests/projects_hub_render_connect.mjs +295 -0
  236. package/tests/provision_render.mjs +121 -28
  237. package/tests/provision_settings_apply.mjs +62 -0
  238. package/tests/provisioning_app_status.mjs +204 -0
  239. package/tests/provisioning_render_route.mjs +126 -6
  240. package/tests/render_check.mjs +2 -1
  241. package/tests/render_deploy.mjs +495 -0
  242. package/tests/ship_preflight.mjs +61 -8
  243. package/tests/smoke_dependencies_witness.mjs +46 -0
  244. package/tests/speciality_skills.mjs +214 -0
  245. package/tests/studio_room_height.mjs +70 -0
  246. package/tests/task_detail_includes.mjs +10 -0
  247. package/tests/task_visuals_instance_root.mjs +150 -0
  248. package/tests/tweak_batch_apply.mjs +21 -0
  249. package/tests/ui_design_kit.mjs +36 -4
  250. package/tests/upgrade.mjs +1 -1
  251. package/tests/upgrade_persist_pin.mjs +24 -0
@@ -0,0 +1,162 @@
1
+ // tests/npm_release_preview_proxy.mjs — forwarding a request to the preview and putting the
2
+ // "You are viewing Bongos X" banner on every page that comes back (task 1004300).
3
+ //
4
+ // WHAT IS AT RISK. The proxy sits in front of a whole hall. It must be transparent (the Host
5
+ // header, method, path, query and body reach the preview unchanged, because the hall routes
6
+ // on Host), it must not buffer a live event stream, and it must put the banner on every HTML
7
+ // page, including a page from a version too old to know about banners. The upstream is
8
+ // injected: no socket is opened.
9
+ //
10
+ // Run: node --test tests/npm_release_preview_proxy.mjs
11
+ import assert from 'node:assert/strict';
12
+ import { test } from 'node:test';
13
+ import { Readable, Writable, PassThrough } from 'node:stream';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const { createProxy, injectBanner, bannerHtml, withoutPreviewCookie } = require('../modules/npm-release/preview/proxy.js');
18
+
19
+ // A stand-in for node:http whose request() records what it was asked and answers from `respond`.
20
+ function fakeHttp(respond) {
21
+ const seen = [];
22
+ return {
23
+ seen,
24
+ request(opts, cb) {
25
+ const chunks = [];
26
+ const rec = { opts, body: '' };
27
+ seen.push(rec);
28
+ return new Writable({
29
+ write(c, _e, n) { chunks.push(c); n(); },
30
+ final(n) {
31
+ rec.body = Buffer.concat(chunks).toString('utf8');
32
+ const r = respond(opts, rec.body);
33
+ if (r instanceof Error) { this.destroy(r); n(); return; }
34
+ const res = r.stream || Readable.from(r.body === undefined ? [] : [Buffer.from(r.body)]);
35
+ res.statusCode = r.status || 200;
36
+ res.headers = r.headers || {};
37
+ cb(res);
38
+ n();
39
+ },
40
+ });
41
+ },
42
+ };
43
+ }
44
+
45
+ class FakeRes extends Writable {
46
+ constructor() { super(); this.parts = []; this.headersSent = false; }
47
+ writeHead(status, headers) { this.status = status; this.headers = headers; this.headersSent = true; }
48
+ _write(chunk, _e, n) { this.parts.push(Buffer.from(chunk)); n(); }
49
+ get text() { return Buffer.concat(this.parts).toString('utf8'); }
50
+ }
51
+
52
+ function request(over = {}) {
53
+ const r = Readable.from(over.body ? [Buffer.from(over.body)] : []);
54
+ r.method = over.method || 'GET';
55
+ r.originalUrl = over.url || '/builders/tasks?x=1&y=2';
56
+ r.headers = { host: 'builders.example.test', cookie: 'sid=abc; bongos_preview=1.19.1000; theme=dark', 'accept-encoding': 'gzip', connection: 'keep-alive', ...(over.headers || {}) };
57
+ return r;
58
+ }
59
+
60
+ const banner = () => ({ viewing: '1.19.1000', running: '1.19.1050' });
61
+ const make = (http) => createProxy({ port: () => 3190, banner, exitHref: '/api/bongos/npm-release/preview/exit', httpImpl: http });
62
+ const finish = (res) => new Promise((r) => (res.writableFinished ? r() : res.on('finish', r)));
63
+ const tick = () => new Promise((r) => setImmediate(r));
64
+
65
+ test('the request goes to the loopback preview port with Host, method, path, query and body unchanged', async () => {
66
+ const http = fakeHttp(() => ({ headers: { 'content-type': 'application/json' }, body: '{}' }));
67
+ const res = new FakeRes();
68
+ make(http)(request({ method: 'POST', url: '/api/bongos/thing?a=1&b=2', body: '{"hello":1}' }), res);
69
+ await finish(res);
70
+ const { opts, body } = http.seen[0];
71
+ assert.equal(opts.host, '127.0.0.1');
72
+ assert.equal(opts.port, 3190);
73
+ assert.equal(opts.method, 'POST');
74
+ assert.equal(opts.path, '/api/bongos/thing?a=1&b=2');
75
+ assert.equal(opts.headers.host, 'builders.example.test', 'the hall is routed on Host');
76
+ assert.equal(body, '{"hello":1}');
77
+ });
78
+
79
+ test('the steering cookie is not passed on; every other cookie is', async () => {
80
+ const http = fakeHttp(() => ({ body: 'x' }));
81
+ const res = new FakeRes();
82
+ make(http)(request(), res);
83
+ await finish(res);
84
+ assert.equal(http.seen[0].opts.headers.cookie, 'sid=abc; theme=dark');
85
+ assert.equal(withoutPreviewCookie('bongos_preview=1.2.3'), '');
86
+ });
87
+
88
+ test('hop-by-hop headers are dropped and the preview is asked for an uncompressed body', async () => {
89
+ const http = fakeHttp(() => ({ body: 'x' }));
90
+ const res = new FakeRes();
91
+ make(http)(request(), res);
92
+ await finish(res);
93
+ const h = http.seen[0].opts.headers;
94
+ assert.equal(h.connection, undefined);
95
+ assert.equal(h['accept-encoding'], 'identity');
96
+ });
97
+
98
+ test('an HTML page gets the banner right after the opening body tag, and a fresh content length', async () => {
99
+ const page = '<!doctype html><html><head><title>t</title></head><body class="hall"><main>hi</main></body></html>';
100
+ const http = fakeHttp(() => ({ headers: { 'content-type': 'text/html; charset=utf-8', 'content-length': String(page.length), etag: 'W/"x"' }, body: page }));
101
+ const res = new FakeRes();
102
+ make(http)(request(), res);
103
+ await finish(res);
104
+ assert.equal(res.status, 200);
105
+ assert.match(res.text, /<body class="hall"><div id="bongos-preview-banner"/);
106
+ assert.match(res.text, /You are viewing Bongos 1\.19\.1000 &mdash; this hall runs 1\.19\.1050/);
107
+ assert.match(res.text, /href="\/api\/bongos\/npm-release\/preview\/exit"/);
108
+ assert.match(res.text, /<main>hi<\/main>/);
109
+ assert.equal(res.headers['content-length'], undefined, 'the old length no longer fits');
110
+ assert.equal(res.headers.etag, undefined);
111
+ assert.equal(res.headers['cache-control'], 'no-store');
112
+ });
113
+
114
+ test('a very old page with no banner code of its own still shows the banner (it is added here)', () => {
115
+ const out = injectBanner('<html><BODY><p>1.0 hall</p></BODY></html>', bannerHtml({ viewing: '1.0.1', running: '1.19.9', exitHref: '/x' }));
116
+ assert.match(out, /<BODY><div id="bongos-preview-banner"/);
117
+ });
118
+
119
+ test('a document with no body tag is left as it was', () => {
120
+ assert.equal(injectBanner('{"not":"html"}', 'BANNER'), '{"not":"html"}');
121
+ });
122
+
123
+ test('the banner escapes what it is given', () => {
124
+ const b = bannerHtml({ viewing: '<script>1</script>', running: 'a"b', exitHref: '/x' });
125
+ assert.doesNotMatch(b, /<script>/);
126
+ assert.match(b, /&lt;script&gt;/);
127
+ });
128
+
129
+ test('JSON and other non-page answers pass through untouched, headers included', async () => {
130
+ const http = fakeHttp(() => ({ status: 201, headers: { 'content-type': 'application/json', 'content-length': '11', etag: 'abc' }, body: '{"ok":true}' }));
131
+ const res = new FakeRes();
132
+ make(http)(request(), res);
133
+ await finish(res);
134
+ assert.equal(res.status, 201);
135
+ assert.equal(res.text, '{"ok":true}');
136
+ assert.equal(res.headers['content-length'], '11');
137
+ assert.doesNotMatch(res.text, /banner/);
138
+ });
139
+
140
+ test('an event stream is streamed, not held: the first chunk arrives before the stream ends', async () => {
141
+ const stream = new PassThrough();
142
+ const http = fakeHttp(() => ({ headers: { 'content-type': 'text/event-stream' }, stream }));
143
+ const res = new FakeRes();
144
+ make(http)(request(), res);
145
+ await tick();
146
+ stream.write('data: one\n\n');
147
+ await tick();
148
+ assert.equal(res.text, 'data: one\n\n', 'delivered while the upstream is still open');
149
+ assert.equal(res.writableFinished, false);
150
+ stream.end();
151
+ await finish(res);
152
+ assert.doesNotMatch(res.text, /banner/);
153
+ });
154
+
155
+ test('a preview that is not answering is a plain 502, not a crash', async () => {
156
+ const http = fakeHttp(() => new Error('ECONNREFUSED'));
157
+ const res = new FakeRes();
158
+ make(http)(request(), res);
159
+ await finish(res);
160
+ assert.equal(res.status, 502);
161
+ assert.match(res.text, /not answering/);
162
+ });
@@ -0,0 +1,208 @@
1
+ // tests/npm_release_preview_routes.mjs — the preview's routes and how they are gated (task 1004300).
2
+ //
3
+ // WHAT IS AT RISK. These routes start a process, copy the live database and set the cookie
4
+ // that steers a browser off the live hall. Each must be behind sign-in AND core.pin.move (an
5
+ // atom, never a rank), and a refused caller must move nothing: no supervisor call at all.
6
+ // The kernel gates are stubbed (no database); the supervisor and the registry are stubs.
7
+ //
8
+ // Run: node --test tests/npm_release_preview_routes.mjs
9
+ import assert from 'node:assert/strict';
10
+ import { test, before, after } from 'node:test';
11
+ import fs from 'node:fs';
12
+ import http from 'node:http';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { createRequire } from 'node:module';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const express = require('express');
20
+ const auth = require('../src/bongos/auth');
21
+ const seams = require('../src/module-seams');
22
+
23
+ // Replace the kernel gates BEFORE the router is built: the module reads them at build time.
24
+ const asked = [];
25
+ auth.requireBuilder = (req, res, next) => {
26
+ const sid = /sid=(\w+)/.exec(req.headers.cookie || '');
27
+ if (!sid) return res.status(401).json({ error: { code: 'unauthenticated' } });
28
+ req.builder = { id: 7, perms: sid[1] === 'good' ? ['core.pin.move'] : [] };
29
+ return next();
30
+ };
31
+ auth.requirePermission = (...atoms) => {
32
+ asked.push(atoms);
33
+ return (req, res, next) => (atoms.every((a) => req.builder.perms.includes(a)) ? next() : res.status(403).json({ error: { code: 'permission_forbidden' } }));
34
+ };
35
+ const buildRouter = require('../modules/npm-release/routes/preview.js');
36
+
37
+ const published = {};
38
+ for (let i = 0; i < 60; i++) published['1.19.' + (1000 + i)] = '2026-09-01T00:00:00Z';
39
+
40
+ let state;
41
+ const calls = [];
42
+ const supervisor = {
43
+ status: () => ({ ...state, port: 3190, idle_ms: 1200000 }),
44
+ start(v) { calls.push(['start', v]); if (state.state === 'running' && state.version !== v) return { ok: false, code: 'preview_busy' }; state = { state: 'starting', version: v, stage: 'queued' }; return { ok: true }; },
45
+ async stop() { calls.push(['stop']); state = { state: 'idle', version: null }; },
46
+ touch() { calls.push(['touch']); },
47
+ };
48
+ let registry = published;
49
+
50
+ let server;
51
+ let base;
52
+ before(async () => {
53
+ state = { state: 'idle', version: null };
54
+ const app = express();
55
+ app.use(express.json());
56
+ app.use('/api/bongos', buildRouter({ supervisor, listPublished: async () => registry, runningVersion: () => '1.19.1040' }));
57
+ server = http.createServer(app);
58
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
59
+ base = server.address().port;
60
+ });
61
+ after(() => new Promise((r) => server.close(r)));
62
+
63
+ function call(method, url, { cookie = 'sid=good', body } = {}) {
64
+ return new Promise((resolve, reject) => {
65
+ const data = body === undefined ? null : JSON.stringify(body);
66
+ const headers = { host: 'builders.example.test' };
67
+ if (cookie) headers.cookie = cookie;
68
+ if (data) { headers['content-type'] = 'application/json'; headers['content-length'] = Buffer.byteLength(data); }
69
+ const r = http.request({ host: '127.0.0.1', port: base, path: '/api/bongos' + url, method, headers }, (res) => {
70
+ let t = '';
71
+ res.on('data', (c) => (t += c));
72
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, json: t && t[0] === '{' ? JSON.parse(t) : null }));
73
+ });
74
+ r.on('error', reject);
75
+ if (data) r.write(data);
76
+ r.end();
77
+ });
78
+ }
79
+
80
+ const ROUTES = [['GET', '/npm-release/preview'], ['POST', '/npm-release/preview', { version: '1.19.1030' }], ['DELETE', '/npm-release/preview'],
81
+ ['GET', '/npm-release/preview/enter?v=1.19.1030'], ['GET', '/npm-release/preview/exit']];
82
+
83
+ test('every route is gated on core.pin.move, asked for as an atom and never as a rank', () => {
84
+ assert.ok(asked.length >= 1);
85
+ for (const a of asked) assert.deepEqual(a, ['core.pin.move']);
86
+ const src = fs.readFileSync(path.join(ROOT, 'modules/npm-release/routes/preview.js'), 'utf8');
87
+ assert.match(src, /api\.requirePermission\('core\.pin\.move'\)/);
88
+ assert.doesNotMatch(src, /requireRank/);
89
+ });
90
+
91
+ test('every route has the two gates before its handler, on the router itself', () => {
92
+ const router = buildRouter({ supervisor, listPublished: async () => published, registerDivert: false });
93
+ const layers = router.stack.filter((l) => l.route);
94
+ assert.equal(layers.length, 5);
95
+ for (const l of layers) assert.equal(l.route.stack.length, 3, l.route.path + ' has requireBuilder, the permission gate, then the handler');
96
+ });
97
+
98
+ test('a caller who is not signed in, or lacks the permission, moves nothing on any route', async () => {
99
+ for (const [method, url, body] of ROUTES) {
100
+ calls.length = 0;
101
+ const anon = await call(method, url, { cookie: '', body });
102
+ assert.equal(anon.status, 401, method + ' ' + url);
103
+ const weak = await call(method, url, { cookie: 'sid=weak', body });
104
+ assert.equal(weak.status, 403, method + ' ' + url);
105
+ assert.deepEqual(calls, [], 'the supervisor was never touched');
106
+ }
107
+ });
108
+
109
+ test('GET reports what is running and which version the hall runs', async () => {
110
+ state = { state: 'idle', version: null };
111
+ const r = await call('GET', '/npm-release/preview');
112
+ assert.equal(r.status, 200);
113
+ assert.equal(r.json.preview.state, 'idle');
114
+ assert.equal(r.json.running, '1.19.1040');
115
+ });
116
+
117
+ test('POST starts a preview of a published, recent version and answers 202', async () => {
118
+ state = { state: 'idle', version: null };
119
+ calls.length = 0;
120
+ const r = await call('POST', '/npm-release/preview', { body: { version: '1.19.1030' } });
121
+ assert.equal(r.status, 202);
122
+ assert.deepEqual(calls, [['start', '1.19.1030']]);
123
+ assert.equal(r.json.preview.state, 'starting');
124
+ });
125
+
126
+ test('POST refuses what may not be previewed, and starts nothing', async () => {
127
+ const cases = [
128
+ [{ version: 'not-a-version' }, 400, 'not_a_version'],
129
+ [{ version: '1.19.9999' }, 400, 'not_published'],
130
+ [{ version: '1.19.1005' }, 400, 'too_old'],
131
+ [{}, 400, 'validation_failed'],
132
+ ];
133
+ for (const [body, status, code] of cases) {
134
+ state = { state: 'idle', version: null };
135
+ calls.length = 0;
136
+ const r = await call('POST', '/npm-release/preview', { body });
137
+ assert.equal(r.status, status, JSON.stringify(body));
138
+ assert.equal((r.json.error || {}).code, code, JSON.stringify(body));
139
+ assert.deepEqual(calls, []);
140
+ }
141
+ });
142
+
143
+ test('POST does not start when the registry cannot be read', async () => {
144
+ state = { state: 'idle', version: null };
145
+ calls.length = 0;
146
+ registry = null;
147
+ try {
148
+ const r = await call('POST', '/npm-release/preview', { body: { version: '1.19.1030' } });
149
+ assert.equal(r.status, 502);
150
+ assert.deepEqual(calls, []);
151
+ } finally { registry = published; }
152
+ });
153
+
154
+ test('POST while a different version runs is a 409 and does not disturb it', async () => {
155
+ state = { state: 'running', version: '1.19.1031' };
156
+ calls.length = 0;
157
+ const r = await call('POST', '/npm-release/preview', { body: { version: '1.19.1030' } });
158
+ assert.equal(r.status, 409);
159
+ assert.equal(r.json.error.code, 'preview_busy');
160
+ assert.equal(state.version, '1.19.1031');
161
+ });
162
+
163
+ test('DELETE stops the preview', async () => {
164
+ state = { state: 'running', version: '1.19.1030' };
165
+ calls.length = 0;
166
+ const r = await call('DELETE', '/npm-release/preview');
167
+ assert.equal(r.status, 200);
168
+ assert.deepEqual(calls, [['stop']]);
169
+ assert.equal(r.json.preview.state, 'idle');
170
+ });
171
+
172
+ test('enter sets the steering cookie for the running version and goes to the hall', async () => {
173
+ state = { state: 'running', version: '1.19.1030' };
174
+ const r = await call('GET', '/npm-release/preview/enter?v=1.19.1030');
175
+ assert.equal(r.status, 302);
176
+ assert.equal(r.headers.location, '/');
177
+ const c = [].concat(r.headers['set-cookie'])[0];
178
+ assert.match(c, /^bongos_preview=1\.19\.1030; Path=\/; HttpOnly; SameSite=Lax/);
179
+ });
180
+
181
+ test('enter refuses a preview that is not the one running, and sets no cookie', async () => {
182
+ for (const [st, v] of [[{ state: 'idle', version: null }, '1.19.1030'], [{ state: 'starting', version: '1.19.1030' }, '1.19.1030'], [{ state: 'running', version: '1.19.1031' }, '1.19.1030'], [{ state: 'running', version: '1.19.1030' }, 'zzz']]) {
183
+ state = st;
184
+ const r = await call('GET', '/npm-release/preview/enter?v=' + v);
185
+ assert.equal(r.status, 409, JSON.stringify(st) + v);
186
+ assert.equal(r.headers['set-cookie'], undefined);
187
+ }
188
+ });
189
+
190
+ test('exit clears the cookie and returns to the deploy page', async () => {
191
+ const r = await call('GET', '/npm-release/preview/exit');
192
+ assert.equal(r.status, 302);
193
+ assert.equal(r.headers.location, '/deploy');
194
+ assert.match([].concat(r.headers['set-cookie'])[0], /^bongos_preview=; Path=\/; Max-Age=0/);
195
+ });
196
+
197
+ test('building the router registers the request.divert provider once, and a rebuild does not throw', () => {
198
+ buildRouter({ supervisor, listPublished: async () => published });
199
+ assert.equal(seams.hasProvider('request.divert'), true);
200
+ assert.equal(typeof seams.resolve('request.divert'), 'function');
201
+ assert.doesNotThrow(() => buildRouter({ supervisor, listPublished: async () => published }));
202
+ });
203
+
204
+ test('the module declares the preview route so the loader mounts it', () => {
205
+ const m = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/npm-release/module.json'), 'utf8'));
206
+ assert.ok(m.contributes.routes.includes('preview'));
207
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules/npm-release/routes/preview.js')));
208
+ });
@@ -0,0 +1,203 @@
1
+ // tests/npm_release_preview_supervisor.mjs — the version preview's lifecycle (task 1004300).
2
+ //
3
+ // WHAT IS AT RISK. A preview is a second process and a second database on the live droplet.
4
+ // The supervisor must (a) run the steps in the order that makes the copy safe, (b) leave
5
+ // NOTHING behind when any step fails, (c) allow only one preview and refuse a second, and
6
+ // (d) stop and drop the copy when idle. Every process, database, network and clock is
7
+ // injected; nothing here spawns, dumps, installs or sleeps.
8
+ //
9
+ // Run: node --test tests/npm_release_preview_supervisor.mjs
10
+ import assert from 'node:assert/strict';
11
+ import { test } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { createSupervisor, previewable, IDLE_MS, WINDOW } = require('../modules/npm-release/preview/supervisor.js');
16
+ const { PREVIEW_DB } = require('../modules/npm-release/preview/env.js');
17
+
18
+ function rig({ failOn, probeResults, env } = {}) {
19
+ const calls = [];
20
+ const rmCalls = [];
21
+ const fake = { clock: 1_000_000, spawned: [], killed: 0 };
22
+ const sup = createSupervisor({
23
+ run: async (cmd) => {
24
+ calls.push(cmd);
25
+ const tag = cmd.cmd === 'pg_dump' ? 'dump' : cmd.cmd;
26
+ if (failOn && failOn === tag) return { code: 3, stderr: 'boom from ' + tag };
27
+ return { code: 0, stderr: '' };
28
+ },
29
+ spawn: (bin, args, opts) => {
30
+ const handlers = {};
31
+ const child = { bin, args, opts, on: (ev, fn) => { handlers[ev] = fn; }, kill: () => { fake.killed += 1; }, emit: (ev, ...a) => handlers[ev] && handlers[ev](...a) };
32
+ fake.spawned.push(child);
33
+ return child;
34
+ },
35
+ probe: async () => (probeResults ? probeResults.shift() ?? false : true),
36
+ sleep: async () => {},
37
+ now: () => fake.clock,
38
+ fsImpl: { mkdtempSync: (p) => p + 'XYZ', mkdirSync() {}, rmSync: (p) => rmCalls.push(p) },
39
+ tmpdir: '/tmp',
40
+ liveDb: 'live_db',
41
+ cacheRoot: '/cache',
42
+ port: 3190,
43
+ env: env || { PATH: '/bin', GITHUB_TOKEN: 'ghp_secret' },
44
+ });
45
+ return { sup, calls, rmCalls, fake };
46
+ }
47
+
48
+ const fwd = (x) => String(x).split(String.fromCharCode(92)).join('/');
49
+ const DROP = { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] };
50
+
51
+ test('a start runs install, copy, restore and migrate, in that order, then launches and waits for health', async () => {
52
+ const { sup, calls, fake } = rig();
53
+ const r = sup.start('1.19.1000');
54
+ assert.equal(r.ok, true);
55
+ assert.equal(r.status.state, 'starting');
56
+ await r.done;
57
+ assert.deepEqual(calls.map((c) => c.cmd), ['npm', 'dropdb', 'createdb', 'pg_dump', 'pg_restore', 'bash']);
58
+ assert.equal(fake.spawned.length, 1);
59
+ assert.match(fwd(fake.spawned[0].args[0]), /1\.19\.1000\/node_modules\/@bongos\/core\/src\/platform-server\.js$/);
60
+ assert.equal(sup.status().state, 'running');
61
+ assert.equal(sup.status().version, '1.19.1000');
62
+ });
63
+
64
+ test('the child is launched with the built environment, not the live one', async () => {
65
+ const { sup, fake } = rig();
66
+ await sup.start('1.19.1000').done;
67
+ const env = fake.spawned[0].opts.env;
68
+ assert.equal(env.GITHUB_TOKEN, undefined);
69
+ assert.equal(env.PGDATABASE, PREVIEW_DB);
70
+ assert.equal(env.PORT, '3190');
71
+ assert.equal(env.HOST, '127.0.0.1');
72
+ });
73
+
74
+ test('the install and the migrate get the built environment too, never the live one', async () => {
75
+ const { sup, calls } = rig();
76
+ await sup.start('1.19.1000').done;
77
+ const install = calls.find((c) => c.cmd === 'npm');
78
+ const migrate = calls.find((c) => c.cmd === 'bash');
79
+ for (const cmd of [install, migrate]) {
80
+ assert.equal(cmd.env.GITHUB_TOKEN, undefined, cmd.cmd);
81
+ assert.equal(cmd.env.PGDATABASE, PREVIEW_DB, cmd.cmd);
82
+ }
83
+ assert.equal(migrate.env.DATABASE_URL, '');
84
+ });
85
+
86
+ for (const failOn of ['npm', 'dropdb', 'createdb', 'dump', 'pg_restore', 'bash']) {
87
+ test('a failed ' + failOn + ' step leaves nothing behind: copy dropped, scratch removed, no child', async () => {
88
+ const { sup, calls, rmCalls, fake } = rig({ failOn });
89
+ await sup.start('1.19.1000').done;
90
+ const s = sup.status();
91
+ assert.equal(s.state, 'failed');
92
+ assert.match(s.error, /failed \(exit 3\)/);
93
+ assert.equal(fake.spawned.length, 0, 'no process was started');
94
+ assert.deepEqual(calls[calls.length - 1], DROP, 'the copy is dropped last');
95
+ assert.equal(rmCalls.length, 1, 'the scratch directory is removed');
96
+ });
97
+ }
98
+
99
+ test('a preview that never answers is stopped and cleaned up', async () => {
100
+ const { sup, fake, rmCalls } = rig({ probeResults: new Array(100).fill(false) });
101
+ await sup.start('1.19.1000').done;
102
+ assert.equal(sup.status().state, 'failed');
103
+ assert.match(sup.status().error, /did not answer/);
104
+ assert.equal(fake.killed, 1);
105
+ assert.equal(rmCalls.length, 1);
106
+ });
107
+
108
+ test('only one preview at a time: a second version is refused, the same version is not restarted', async () => {
109
+ const { sup, calls } = rig();
110
+ await sup.start('1.19.1000').done;
111
+ const before = calls.length;
112
+ const other = sup.start('1.19.1001');
113
+ assert.equal(other.ok, false);
114
+ assert.equal(other.code, 'preview_busy');
115
+ const same = sup.start('1.19.1000');
116
+ assert.equal(same.ok, true);
117
+ assert.equal(same.already, true);
118
+ assert.equal(calls.length, before, 'nothing ran');
119
+ });
120
+
121
+ test('a second start while the first is still starting is refused too', () => {
122
+ const { sup } = rig();
123
+ sup.start('1.19.1000');
124
+ const other = sup.start('1.19.1001');
125
+ assert.equal(other.code, 'preview_busy');
126
+ });
127
+
128
+ test('a version that is not a version is refused before anything runs', () => {
129
+ const { sup, calls } = rig();
130
+ const r = sup.start('1.19.1; rm -rf /');
131
+ assert.equal(r.ok, false);
132
+ assert.equal(r.code, 'not_a_version');
133
+ assert.equal(calls.length, 0);
134
+ });
135
+
136
+ test('stop kills the child, drops the copy, removes scratch and returns to idle', async () => {
137
+ const { sup, calls, fake, rmCalls } = rig();
138
+ await sup.start('1.19.1000').done;
139
+ await sup.stop();
140
+ assert.equal(fake.killed, 1);
141
+ assert.deepEqual(calls[calls.length - 1], DROP);
142
+ assert.equal(rmCalls.length, 1);
143
+ assert.equal(sup.status().state, 'idle');
144
+ assert.equal(sup.status().version, null);
145
+ assert.equal(sup.start('1.19.1001').ok, true, 'and a new one may start');
146
+ });
147
+
148
+ test('a running preview nobody touches for the idle window is stopped; touching keeps it', async () => {
149
+ const { sup, fake } = rig();
150
+ await sup.start('1.19.1000').done;
151
+ fake.clock += IDLE_MS - 1000;
152
+ assert.equal(await sup.reapIdle(), false, 'just inside the window');
153
+ sup.touch();
154
+ fake.clock += IDLE_MS - 1000;
155
+ assert.equal(await sup.reapIdle(), false, 'the touch restarted the window');
156
+ fake.clock += 2000;
157
+ assert.equal(await sup.reapIdle(), true);
158
+ assert.equal(sup.status().state, 'idle');
159
+ assert.equal(fake.killed, 1);
160
+ });
161
+
162
+ test('the idle window is twenty minutes', () => {
163
+ assert.equal(IDLE_MS, 20 * 60 * 1000);
164
+ });
165
+
166
+ test('reaping does nothing when nothing is running', async () => {
167
+ const { sup, calls } = rig();
168
+ assert.equal(await sup.reapIdle(), false);
169
+ assert.equal(calls.length, 0);
170
+ });
171
+
172
+ test('a child that dies on its own leaves the state failed and the copy dropped', async () => {
173
+ const { sup, fake, calls } = rig();
174
+ await sup.start('1.19.1000').done;
175
+ fake.spawned[0].emit('exit', 1);
176
+ await new Promise((r) => setImmediate(r));
177
+ assert.equal(sup.status().state, 'failed');
178
+ assert.deepEqual(calls[calls.length - 1], DROP);
179
+ });
180
+
181
+ // ---- which versions may be opened ----------------------------------------------------
182
+
183
+ const published = {};
184
+ for (let i = 0; i < 60; i++) published['1.19.' + (1000 + i)] = '2026-09-01T00:00:00Z';
185
+
186
+ test('only a published version may be previewed', () => {
187
+ assert.deepEqual(previewable('1.19.1010', { published, running: '1.19.1040' }), { ok: true });
188
+ assert.deepEqual(previewable('1.19.9999', { published, running: '1.19.1040' }), { ok: false, reason: 'not_published' });
189
+ assert.deepEqual(previewable('nope', { published, running: '1.19.1040' }), { ok: false, reason: 'not_a_version' });
190
+ });
191
+
192
+ test('no older than the running version minus thirty published versions; newer is fine', () => {
193
+ const running = '1.19.1040';
194
+ assert.equal(WINDOW, 30);
195
+ assert.equal(previewable('1.19.1010', { published, running }).ok, true, 'exactly thirty back');
196
+ assert.deepEqual(previewable('1.19.1009', { published, running }), { ok: false, reason: 'too_old', oldest: '1.19.1010' });
197
+ assert.equal(previewable('1.19.1059', { published, running }).ok, true, 'a newer one may be tried');
198
+ });
199
+
200
+ test('when the running version is not in the list the window counts from the newest', () => {
201
+ assert.equal(previewable('1.19.1029', { published, running: '0.0.1' }).ok, true);
202
+ assert.equal(previewable('1.19.1028', { published, running: '0.0.1' }).ok, false);
203
+ });