@bongos/core 1.19.1081 → 1.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/.bongos-core.json +528 -193
  2. package/.claude/skills/planning-session/SKILL.md +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +32 -0
  5. package/clients/bongos-client/index.cjs +32 -0
  6. package/clients/bongos-client/index.d.ts +51 -6
  7. package/clients/bongos-client/index.mjs +32 -0
  8. package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
  9. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  10. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
  11. package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
  12. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  13. package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  14. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  15. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  16. package/docs/adr/README.md +3 -0
  17. package/docs/api/openapi.json +851 -19
  18. package/docs/api-reference.md +33 -9
  19. package/docs/architecture.md +6 -2
  20. package/docs/copy-inventory.md +620 -553
  21. package/docs/copy-registry.json +1467 -820
  22. package/docs/file-map.md +2 -0
  23. package/docs/module-api-changelog.md +8 -0
  24. package/docs/modules-contract.md +1 -0
  25. package/docs/page-inventory.json +38 -4
  26. package/docs/page-readings.json +1666 -1558
  27. package/migrations/core_260_goals_working_area.sql +63 -0
  28. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  29. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  30. package/modules/agents/lib/validate.js +43 -0
  31. package/modules/autonomy/gauge.js +38 -2
  32. package/modules/grading/grader-subagent.js +37 -2
  33. package/modules/grading/grader-workers/reader.js +65 -5
  34. package/modules/hall-ui/public/approval-queue.css +33 -7
  35. package/modules/hall-ui/public/approval-queue.js +9 -2
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/brand-holes.js +121 -0
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/collab.js +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +6 -1
  44. package/modules/hall-ui/public/deploy.js +22 -8
  45. package/modules/hall-ui/public/diagrams.html +1 -1
  46. package/modules/hall-ui/public/dom-utils.js +20 -0
  47. package/modules/hall-ui/public/drachmae.html +1 -1
  48. package/modules/hall-ui/public/fleet.html +1 -1
  49. package/modules/hall-ui/public/gate.html +1 -1
  50. package/modules/hall-ui/public/goal-inbox.js +1 -1
  51. package/modules/hall-ui/public/goals.html +1 -1
  52. package/modules/hall-ui/public/government.html +1 -1
  53. package/modules/hall-ui/public/idea.html +1 -1
  54. package/modules/hall-ui/public/idea.js +28 -0
  55. package/modules/hall-ui/public/ideas.html +1 -1
  56. package/modules/hall-ui/public/ideas.js +19 -3
  57. package/modules/hall-ui/public/index.html +5 -1
  58. package/modules/hall-ui/public/modules.html +1 -1
  59. package/modules/hall-ui/public/primer.html +1 -1
  60. package/modules/hall-ui/public/profile-nudge.js +21 -4
  61. package/modules/hall-ui/public/profile.html +1 -1
  62. package/modules/hall-ui/public/profile.js +1 -1
  63. package/modules/hall-ui/public/project-settings.html +1 -1
  64. package/modules/hall-ui/public/ranks.html +1 -1
  65. package/modules/hall-ui/public/roadmap.html +1 -1
  66. package/modules/hall-ui/public/roster.html +1 -1
  67. package/modules/hall-ui/public/sessions.html +1 -1
  68. package/modules/hall-ui/public/settings.html +19 -30
  69. package/modules/hall-ui/public/settings.js +24 -177
  70. package/modules/hall-ui/public/settings.states.json +1 -1
  71. package/modules/hall-ui/public/shell.js +4 -2
  72. package/modules/hall-ui/public/studio.css +14 -8
  73. package/modules/hall-ui/public/studio.html +5 -4
  74. package/modules/hall-ui/public/task.html +1 -1
  75. package/modules/hall-ui/public/thinking.css +6 -3
  76. package/modules/hall-ui/public/thinking.html +1 -1
  77. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  78. package/modules/hall-ui/public/tweak-editor.css +142 -11
  79. package/modules/hall-ui/public/tweak-editor.html +25 -9
  80. package/modules/hall-ui/public/tweak-editor.js +243 -10
  81. package/modules/hall-ui/public/watch.html +1 -1
  82. package/modules/hall-ui/public/work.html +1 -1
  83. package/modules/hall-ui/records/approval-queue.md +6 -0
  84. package/modules/hall-ui/records/tweak-editor.md +6 -0
  85. package/modules/ideas/ratify.js +36 -8
  86. package/modules/ideas/routes/ratify-goal.js +6 -0
  87. package/modules/lifecycle/db-analytics.js +79 -10
  88. package/modules/lifecycle/db-claims.js +48 -1
  89. package/modules/lifecycle/db-deps-criteria.js +94 -40
  90. package/modules/lifecycle/db-goals.js +4 -1
  91. package/modules/lifecycle/db-grade.js +9 -1
  92. package/modules/lifecycle/db-relevance-flags.js +183 -0
  93. package/modules/lifecycle/db-versions.js +4 -3
  94. package/modules/lifecycle/db.js +19 -0
  95. package/modules/lifecycle/done-when.js +5 -3
  96. package/modules/lifecycle/est-advisory.js +31 -4
  97. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  98. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  99. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  100. package/modules/lifecycle/module.json +2 -1
  101. package/modules/lifecycle/routes/claims.js +8 -1
  102. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  103. package/modules/lifecycle/routes/goals.js +1 -1
  104. package/modules/lifecycle/routes/tasks.js +27 -24
  105. package/modules/lifecycle/routes/visuals.js +9 -0
  106. package/modules/lifecycle/ship-preflight.js +7 -11
  107. package/modules/lifecycle/task-visuals.js +109 -5
  108. package/modules/npm-release/module.json +2 -1
  109. package/modules/npm-release/preview/commands.js +81 -0
  110. package/modules/npm-release/preview/divert.js +95 -0
  111. package/modules/npm-release/preview/env.js +78 -0
  112. package/modules/npm-release/preview/proxy.js +107 -0
  113. package/modules/npm-release/preview/runtime.js +83 -0
  114. package/modules/npm-release/preview/supervisor.js +175 -0
  115. package/modules/npm-release/public/work.js +65 -1
  116. package/modules/npm-release/routes/preview.js +133 -0
  117. package/modules/provisioning/app-status.js +83 -0
  118. package/modules/provisioning/module.json +2 -1
  119. package/modules/provisioning/pollers/app-liveness.js +110 -0
  120. package/modules/provisioning/render-lookup.js +68 -0
  121. package/modules/provisioning/render-standup.js +44 -1
  122. package/modules/provisioning/routes/render-standup.js +81 -6
  123. package/modules/provisioning/starter-bundles.js +4 -1
  124. package/modules/public-landing/public/account-private.states.json +14 -0
  125. package/modules/public-landing/public/account.html +513 -0
  126. package/modules/public-landing/public/account.probes.json +37 -0
  127. package/modules/public-landing/public/account.states.json +16 -0
  128. package/modules/public-landing/public/index.html +5 -2
  129. package/modules/public-landing/public/projects.html +661 -18
  130. package/modules/public-landing/public/projects.states.json +8 -1
  131. package/modules/render-deploy/deploys.js +203 -0
  132. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  133. package/modules/render-deploy/module.json +25 -0
  134. package/modules/render-deploy/public/deploy.css +10 -0
  135. package/modules/render-deploy/public/deploy.js +249 -0
  136. package/modules/render-deploy/render.js +56 -0
  137. package/modules/render-deploy/routes/act.js +88 -0
  138. package/modules/render-deploy/routes/app.js +65 -0
  139. package/modules/render-deploy/routes/history.js +38 -0
  140. package/modules/specialities/db.js +27 -8
  141. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  142. package/modules/specialities/routes/specialities.js +51 -3
  143. package/modules/specialities/skills.js +94 -0
  144. package/modules/specialities/specialities.js +56 -9
  145. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  146. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  147. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  148. package/modules/ui-design/kit/lib.js +3 -1
  149. package/modules/ui-design/kit/serve.js +96 -0
  150. package/package-lock.json +2 -2
  151. package/package.json +1 -1
  152. package/release-notes.json +169 -0
  153. package/scripts/gds/agents-sync.js +13 -3
  154. package/scripts/gds/autobongos-run.js +80 -2
  155. package/scripts/gds/autobongos-service.cmd +12 -0
  156. package/scripts/gds/copy-apply.js +14 -0
  157. package/scripts/gds/dev-box-guard.js +3 -2
  158. package/scripts/gds/fitness.js +9 -0
  159. package/scripts/gds/grade-correlation-audit.js +42 -12
  160. package/scripts/gds/grade-replay.js +1 -1
  161. package/scripts/gds/provision-core-upgrade.js +38 -7
  162. package/scripts/gds/provision-pin-key.js +176 -0
  163. package/scripts/gds/provision-render.js +62 -31
  164. package/scripts/gds/provision-teardown.js +7 -2
  165. package/scripts/gds/provision-units.js +31 -0
  166. package/scripts/gds/provision.js +10 -10
  167. package/scripts/gds/publish-manifest.js +1 -0
  168. package/scripts/gds/render-api.js +19 -0
  169. package/scripts/gds/render-payload.js +39 -65
  170. package/scripts/gds/run-unit-tests.js +10 -0
  171. package/scripts/gds/ship-finish.js +19 -20
  172. package/scripts/gds/smoke-dependencies.sh +37 -8
  173. package/scripts/gds/status.js +12 -4
  174. package/scripts/gds/upgrade.js +2 -2
  175. package/scripts/public-mirror-export.js +7 -1
  176. package/src/bongos/module-scope-map.js +13 -0
  177. package/src/bongos/serve-internal.js +3 -1
  178. package/src/module-api.js +10 -1
  179. package/src/platform-server.js +39 -0
  180. package/tests/account_privacy_flags.mjs +10 -6
  181. package/tests/agents_authoring.mjs +15 -1
  182. package/tests/agents_sync.mjs +106 -5
  183. package/tests/agents_validate.mjs +45 -0
  184. package/tests/api_path_404.mjs +6 -0
  185. package/tests/autobongos_cadence.mjs +7 -0
  186. package/tests/autobongos_loop.mjs +136 -1
  187. package/tests/autonomy_gauge.mjs +62 -0
  188. package/tests/blocker_hall_live_proof.mjs +275 -0
  189. package/tests/claim_gate_ci_unblock.mjs +116 -0
  190. package/tests/claim_gate_rebase.mjs +15 -3
  191. package/tests/collab_page.mjs +10 -0
  192. package/tests/conductor_main_e2e.mjs +97 -0
  193. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  194. package/tests/core_upgrade_runner.mjs +1 -0
  195. package/tests/dependency_writes_atomic.mjs +191 -0
  196. package/tests/deploy_page_projects.mjs +2 -1
  197. package/tests/effective_visibility_predicate.mjs +15 -0
  198. package/tests/est_advisory.mjs +27 -0
  199. package/tests/goal_map_page.mjs +26 -0
  200. package/tests/goal_task_relevance.mjs +518 -0
  201. package/tests/goal_working_area.mjs +146 -0
  202. package/tests/grade_attempts.mjs +61 -0
  203. package/tests/grade_attribution.mjs +23 -0
  204. package/tests/grade_correlation_audit.mjs +54 -1
  205. package/tests/grader_reader_lens.mjs +55 -2
  206. package/tests/grader_root_outage.mjs +6 -1
  207. package/tests/grader_subagent_tools_arg.mjs +102 -0
  208. package/tests/hall_approval_queue.mjs +54 -2
  209. package/tests/hall_settings_world.mjs +3 -1
  210. package/tests/hall_tweak_editor.mjs +316 -11
  211. package/tests/hub_account_page.mjs +394 -0
  212. package/tests/idea_detail_page.mjs +64 -0
  213. package/tests/idea_goal_ratification.mjs +64 -0
  214. package/tests/idea_spark_hall.mjs +60 -9
  215. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  216. package/tests/landing_page.mjs +6 -3
  217. package/tests/module-scope-map.mjs +31 -8
  218. package/tests/module_api.mjs +1 -0
  219. package/tests/module_loader.mjs +1 -1
  220. package/tests/nav_permission_atoms.mjs +1 -1
  221. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  222. package/tests/npm_release_preview_commands.mjs +89 -0
  223. package/tests/npm_release_preview_divert.mjs +164 -0
  224. package/tests/npm_release_preview_env.mjs +100 -0
  225. package/tests/npm_release_preview_proxy.mjs +162 -0
  226. package/tests/npm_release_preview_routes.mjs +208 -0
  227. package/tests/npm_release_preview_supervisor.mjs +203 -0
  228. package/tests/pin_write_key.mjs +383 -0
  229. package/tests/planning_session_skill.mjs +27 -0
  230. package/tests/platform_boot.mjs +71 -0
  231. package/tests/profile_nudge_links.mjs +86 -0
  232. package/tests/profile_ui_cross_project.mjs +1 -1
  233. package/tests/projects_hub.mjs +2 -2
  234. package/tests/projects_hub_app_status.mjs +249 -0
  235. package/tests/projects_hub_app_step.mjs +282 -52
  236. package/tests/projects_hub_render_connect.mjs +295 -0
  237. package/tests/provision_render.mjs +121 -28
  238. package/tests/provision_settings_apply.mjs +62 -0
  239. package/tests/provisioning_app_status.mjs +204 -0
  240. package/tests/provisioning_render_route.mjs +126 -6
  241. package/tests/render_check.mjs +2 -1
  242. package/tests/render_deploy.mjs +495 -0
  243. package/tests/rot_inbox.mjs +8 -0
  244. package/tests/ship_preflight.mjs +61 -8
  245. package/tests/smoke_dependencies_witness.mjs +46 -0
  246. package/tests/speciality_skills.mjs +214 -0
  247. package/tests/studio_room_height.mjs +70 -0
  248. package/tests/task_detail_includes.mjs +10 -0
  249. package/tests/task_visuals_instance_root.mjs +150 -0
  250. package/tests/tweak_batch_apply.mjs +21 -0
  251. package/tests/ui_design_kit.mjs +36 -4
  252. package/tests/upgrade.mjs +1 -1
  253. package/tests/upgrade_persist_pin.mjs +24 -0
@@ -0,0 +1,204 @@
1
+ // tests/provisioning_app_status.mjs — what the owner is told about their app's setup on
2
+ // Render, and the knock that says when it is live (task 1004353).
3
+ //
4
+ // The load-bearing assertions:
5
+ // • the read is OWNER ONLY, returns env var NAMES only, and never the key or a value
6
+ // • a failure comes back as one sentence, with the answers a Retry re-sends — the
7
+ // answers only, never a key
8
+ // • an app address is only ever an onrender.com one, and a Render link is only ever
9
+ // built from a checked service id — a stored value cannot shape either
10
+ // • the poller marks an app live only when it answers at that address, never on
11
+ // Render's no-deploy page, never on a 5xx; it follows no redirect; and its write is
12
+ // compare-and-set, so it never overwrites what the runner wrote since
13
+ //
14
+ // DB-free and network-free. Run: node tests/provisioning_app_status.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+ import { readFileSync } from 'node:fs';
19
+ import http from 'node:http';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const { appStatus, appUrl, serviceLinks, oneSentence, retryAnswers } = require('../modules/provisioning/app-status.js');
23
+ const liveness = require('../modules/provisioning/pollers/app-liveness.js');
24
+
25
+ let passed = 0, failed = 0;
26
+ async function ta(name, fn) {
27
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
28
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
29
+ }
30
+
31
+ const HOST = { platform: 'render', mode: 'greenfield', ownerId: 'tea-aaaaaaaa1111', serviceId: 'srv-aaaaaaaa1111', serviceUrl: 'https://demo.onrender.com',
32
+ runtime: 'node', startCommand: 'npm start', buildCommand: 'npm ci', webPlan: 'starter', database: true, dbPlan: 'basic_256mb' };
33
+
34
+ console.log('the status (app-status.js):');
35
+ await ta('no app host, or another platform → null', () => {
36
+ assert.equal(appStatus(null), null);
37
+ assert.equal(appStatus({ platform: 'vercel' }), null);
38
+ });
39
+ await ta('each runner state maps to the stage the owner sees', () => {
40
+ const stage = (state) => appStatus({ ...HOST, state }).stage;
41
+ assert.equal(stage('requested'), 'creating');
42
+ assert.equal(stage('creating'), 'creating');
43
+ assert.equal(stage('deploying'), 'building');
44
+ assert.equal(stage('linked'), 'building', 'a link waits for the app to answer too');
45
+ assert.equal(stage('live'), 'live');
46
+ assert.equal(appStatus({ ...HOST, state: 'failed', error: 'x' }).stage, null, 'a failure is not a stage');
47
+ });
48
+ await ta('a live app carries its address and the two Render links', () => {
49
+ const s = appStatus({ ...HOST, state: 'live' });
50
+ assert.equal(s.url, 'https://demo.onrender.com');
51
+ assert.deepEqual(s.links, { service: 'https://dashboard.render.com/web/srv-aaaaaaaa1111', env: 'https://dashboard.render.com/web/srv-aaaaaaaa1111/env' });
52
+ assert.equal(s.error, null);
53
+ assert.equal(s.retry, null);
54
+ });
55
+ await ta('a stored value cannot shape the address or a link', () => {
56
+ assert.equal(appUrl('https://evil.example.com'), null);
57
+ assert.equal(appUrl('http://demo.onrender.com'), null, 'https only');
58
+ assert.equal(appUrl('https://demo.onrender.com.evil.io'), null);
59
+ assert.equal(appUrl('javascript:alert(1)'), null);
60
+ assert.equal(serviceLinks('srv-x/../../account'), null);
61
+ assert.equal(serviceLinks(null), null);
62
+ });
63
+ await ta('a failure is ONE sentence, and a Retry re-sends the answers — never a key', () => {
64
+ const s = appStatus({ ...HOST, state: 'failed', error: 'this Render key cannot reach workspace tea-aaaaaaaa1111\nstack…' });
65
+ assert.equal(s.error, 'This Render key cannot reach workspace tea-aaaaaaaa1111.');
66
+ assert.deepEqual(s.retry, { owner_id: 'tea-aaaaaaaa1111', mode: 'greenfield', runtime: 'node', start_command: 'npm start', build_command: 'npm ci', web_plan: 'starter', database: true, db_plan: 'basic_256mb' });
67
+ assert.ok(!('key' in s.retry));
68
+ assert.equal(oneSentence(''), 'The setup stopped without saying why.');
69
+ assert.deepEqual(retryAnswers({ mode: 'adopt', ownerId: 'tea-aaaaaaaa1111', serviceId: 'srv-aaaaaaaa1111' }), { owner_id: 'tea-aaaaaaaa1111', mode: 'adopt', service_id: 'srv-aaaaaaaa1111' });
70
+ assert.deepEqual(retryAnswers({ mode: 'greenfield', ownerId: 'tea-aaaaaaaa1111', runtime: 'docker', startCommand: 'ignored' }), { owner_id: 'tea-aaaaaaaa1111', mode: 'greenfield', runtime: 'docker', database: false });
71
+ });
72
+ await ta('a run still holding the lent key reads as waiting, not as something to retry', () => {
73
+ const s = appStatus({ ...HOST, state: 'requested' }, { keyWaiting: true });
74
+ assert.equal(s.waiting, true);
75
+ assert.equal(s.retry, null);
76
+ });
77
+
78
+ console.log('\nGET /provisioning/instances/:id/render:');
79
+ const provisioning = require('../modules/provisioning/provisioning.js');
80
+ const envManifest = require('../modules/provisioning/env-manifest.js');
81
+ const api = require('../src/module-api.js');
82
+ let instance = null, builder = null, waiting = false, queries = [];
83
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
84
+ envManifest.listEnvVars = async () => [
85
+ { name: 'DATABASE_URL', required: true, description: 'x' },
86
+ { name: 'SESSION_SECRET', required: true },
87
+ { name: 'STRIPE_KEY', required: false },
88
+ ];
89
+ api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
90
+ api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
91
+ Object.defineProperty(api, 'pool', { configurable: true, get: () => ({ query: async (sql, p) => { queries.push(sql); return { rows: [{ waiting }] }; } }) });
92
+ const express = require('express');
93
+ const buildRouter = require('../modules/provisioning/routes/render-standup.js');
94
+ const app = express();
95
+ app.use(express.json());
96
+ app.use((_req, res, next) => { res.fail = (code, opts) => res.status((opts && opts.status) || (typeof opts === 'number' ? opts : 400)).json({ error: { code } }); next(); });
97
+ app.use('/api/bongos', buildRouter());
98
+ const server = app.listen(0);
99
+ await new Promise((r) => server.once('listening', r));
100
+ const get = (id) => new Promise((resolve, reject) => {
101
+ http.get(`http://127.0.0.1:${server.address().port}/api/bongos/provisioning/instances/${id}/render`, (res) => {
102
+ let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d || '{}'), raw: d }));
103
+ }).on('error', reject);
104
+ });
105
+ function reset(host) { instance = { owner_builder_id: 7, slug: 'demo', status: 'active', app_host: host }; builder = { id: 7 }; waiting = false; queries = []; }
106
+
107
+ await ta('the owner reads the status, with env var NAMES only, and DATABASE_URL marked as set for them', async () => {
108
+ reset({ ...HOST, state: 'live' });
109
+ const r = await get(18);
110
+ assert.equal(r.status, 200, r.raw);
111
+ assert.equal(r.body.app.stage, 'live');
112
+ assert.deepEqual(r.body.env_vars, [
113
+ { name: 'DATABASE_URL', required: true, set_for_you: true },
114
+ { name: 'SESSION_SECRET', required: true, set_for_you: false },
115
+ { name: 'STRIPE_KEY', required: false, set_for_you: false },
116
+ ]);
117
+ assert.equal(r.body.values_held, false);
118
+ assert.doesNotMatch(r.raw, /description|borrowed_key|rnd_/, 'no description, no key column, no key');
119
+ });
120
+ await ta('someone else\'s project — even for an archon — and a missing one are the same 404', async () => {
121
+ reset({ ...HOST, state: 'live' }); builder = { id: 42, rank: 'archon' };
122
+ assert.equal((await get(18)).status, 404);
123
+ reset(null); instance = null;
124
+ assert.equal((await get(18)).status, 404);
125
+ });
126
+ await ta('no platform chosen → app null, and no env list', async () => {
127
+ reset(null);
128
+ const r = await get(18);
129
+ assert.equal(r.status, 200);
130
+ assert.equal(r.body.app, null);
131
+ assert.deepEqual(r.body.env_vars, []);
132
+ });
133
+ await ta('a lent key still waiting on a run reads as waiting', async () => {
134
+ reset({ ...HOST, state: 'requested' }); waiting = true;
135
+ const r = await get(18);
136
+ assert.equal(r.body.app.waiting, true);
137
+ assert.match(queries[0], /borrowed_key_enc IS NOT NULL/, 'asked as a yes/no — the key column is never selected');
138
+ assert.doesNotMatch(queries[0], /SELECT borrowed_key_enc/);
139
+ });
140
+ await ta('the read is capped per builder — two surfaces polling fit well inside it', async () => {
141
+ reset({ ...HOST, state: 'live' }); builder = { id: 777 };
142
+ let last = null;
143
+ for (let i = 0; i < 61; i++) last = await get(18);
144
+ assert.equal(last.status, 429);
145
+ });
146
+ server.close();
147
+
148
+ console.log('\nthe knock (pollers/app-liveness.js):');
149
+ const res = (status, routing) => ({ status, headers: { get: (h) => (h === 'x-render-routing' ? routing || null : null) } });
150
+ await ta('live only when the app answers: not on the no-deploy page, not on a 5xx, not on an error', async () => {
151
+ const f = (r) => async () => r;
152
+ assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: f(res(200)) }), true);
153
+ assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: f(res(404)) }), true, 'a 404 at the root is still an app answering');
154
+ assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: f(res(502, 'no-deploy')) }), false);
155
+ assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: f(res(404, 'no-deploy')) }), false);
156
+ assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: f(res(503)) }), false);
157
+ assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: async () => { throw new Error('ECONNRESET'); } }), false);
158
+ });
159
+ await ta('it knocks only on an onrender.com address, and follows no redirect', async () => {
160
+ const seen = [];
161
+ const fetchImpl = async (url, init) => { seen.push({ url, redirect: init.redirect, auth: init.headers.Authorization }); return res(200); };
162
+ assert.equal(await liveness.appAnswers('https://10.0.0.1', { fetchImpl }), false);
163
+ assert.equal(await liveness.appAnswers('https://metadata.google.internal', { fetchImpl }), false);
164
+ assert.equal(seen.length, 0, 'never even asked');
165
+ await liveness.appAnswers('https://demo.onrender.com', { fetchImpl });
166
+ assert.equal(seen[0].redirect, 'manual');
167
+ assert.equal(seen[0].auth, undefined, 'no credential of any kind');
168
+ });
169
+ await ta('a sweep marks only answering apps live, compare-and-set on the state it read', async () => {
170
+ const writes = [];
171
+ const db = { query: async (sql, params) => {
172
+ if (/^\s*SELECT/.test(sql)) {
173
+ assert.match(sql, /app_host->>'state' IN \('deploying', 'linked'\)/);
174
+ return { rows: [
175
+ { id: 1, slug: 'up', app_host: { state: 'deploying', serviceUrl: 'https://up.onrender.com' } },
176
+ { id: 2, slug: 'building', app_host: { state: 'deploying', serviceUrl: 'https://building.onrender.com' } },
177
+ { id: 3, slug: 'linked', app_host: { state: 'linked', serviceUrl: 'https://linked.onrender.com' } },
178
+ ] };
179
+ }
180
+ writes.push({ sql: sql.replace(/\s+/g, ' '), params });
181
+ return { rowCount: 1 };
182
+ } };
183
+ const fetchImpl = async (url) => (url.includes('building') ? res(502, 'no-deploy') : res(200));
184
+ const out = await liveness.sweepOnce({ pool: db, fetchImpl, log: () => {} });
185
+ assert.deepEqual(out, { checked: 3, live: 2 });
186
+ assert.deepEqual(writes.map((w) => w.params), [[1, 'deploying'], [3, 'linked']]);
187
+ assert.match(writes[0].sql, /WHERE id = \$1 AND app_host->>'state' = \$2/, 'never overwrites what the runner wrote since');
188
+ });
189
+ await ta('the knocks run a few at a time, never all at once and never one by one', async () => {
190
+ let inFlight = 0, peak = 0;
191
+ const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, slug: `a${i}`, app_host: { state: 'deploying', serviceUrl: `https://a${i}.onrender.com` } }));
192
+ const db = { query: async (sql) => (/^\s*SELECT/.test(sql) ? { rows } : { rowCount: 1 }) };
193
+ const fetchImpl = async () => { inFlight++; peak = Math.max(peak, inFlight); await new Promise((r) => setTimeout(r, 5)); inFlight--; return res(200); };
194
+ const out = await liveness.sweepOnce({ pool: db, fetchImpl, log: () => {} });
195
+ assert.deepEqual(out, { checked: 12, live: 12 });
196
+ assert.equal(peak, 5);
197
+ });
198
+ await ta('the module starts the poller', () => {
199
+ const manifest = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
200
+ assert.ok(manifest.contributes.pollers.includes('app-liveness'));
201
+ });
202
+
203
+ console.log(`\nprovisioning_app_status: ${passed} passed, ${failed} failed`);
204
+ process.exit(failed ? 1 : 0);
@@ -85,17 +85,18 @@ function reset(patch = {}) {
85
85
  builder = { id: 7, github_login: 'owner', rank: 'thetes' };
86
86
  }
87
87
  const PATH = '/provisioning/instances/18/render';
88
- const GREEN = { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'greenfield' };
88
+ const GREEN = { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'greenfield', runtime: 'node', start_command: 'npm start' };
89
89
  const noKeyAnywhere = (...xs) => xs.forEach((x) => assert.ok(!JSON.stringify(x).includes(KEY), 'the plaintext key leaked: ' + JSON.stringify(x)));
90
90
 
91
91
  console.log('who may ask:');
92
92
  await ta('the owner queues it: ciphertext only, the request recorded on the row, one audit event', async () => {
93
93
  reset();
94
- const r = await post(PATH, { ...GREEN, web_plan: 'standard' });
94
+ const r = await post(PATH, { ...GREEN, web_plan: 'standard', build_command: ' npm ci ', database: true });
95
95
  assert.equal(r.status, 200, r.raw);
96
96
  assert.equal(enqueueCalls.length, 1);
97
97
  assert.equal(enqueueCalls[0].keyEnc, `v1.sealed(${KEY.length})`);
98
- assert.deepEqual(enqueueCalls[0].appHost, { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234', state: 'requested', error: null, webPlan: 'standard' });
98
+ assert.deepEqual(enqueueCalls[0].appHost, { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234', state: 'requested', error: null,
99
+ runtime: 'node', startCommand: 'npm start', buildCommand: 'npm ci', database: true, webPlan: 'standard' });
99
100
  assert.equal(events.length, 1);
100
101
  noKeyAnywhere(enqueueCalls, events, r.body);
101
102
  });
@@ -122,7 +123,13 @@ for (const [name, body, re] of [
122
123
  ['an unknown mode', { ...GREEN, mode: 'migrate' }, /link a Render service/],
123
124
  ['adopt with no service', { ...GREEN, mode: 'adopt' }, /service to link/],
124
125
  ['greenfield naming a service', { ...GREEN, service_id: 'srv-abcdefgh1234' }, /leave the service out/],
125
- ['an unknown plan', { ...GREEN, db_plan: 'free' }, /not a Render database plan/],
126
+ ['an unknown plan', { ...GREEN, database: true, db_plan: 'free' }, /not a Render database plan/],
127
+ ['a database plan with no database', { ...GREEN, db_plan: 'basic_1gb' }, /needs a database/],
128
+ ['a new app with no runtime', { ...GREEN, runtime: undefined }, /built with/],
129
+ ['an unknown runtime', { ...GREEN, runtime: 'cobol' }, /built with/],
130
+ ['a native runtime with no start command', { ...GREEN, start_command: ' ' }, /how your app starts/],
131
+ ['docker with commands', { ...GREEN, runtime: 'docker' }, /leave the commands out/],
132
+ ['adopt saying how to build', { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'adopt', service_id: 'srv-abcdefgh1234', runtime: 'node' }, /exactly as it is/],
126
133
  ]) {
127
134
  await ta(`${name} → 400, nothing kept`, async () => {
128
135
  reset();
@@ -141,9 +148,17 @@ await ta('an unknown field is refused by the strict validator', async () => {
141
148
  });
142
149
  await ta('adopt with a service id is accepted and records it', async () => {
143
150
  reset();
144
- const r = await post(PATH, { ...GREEN, mode: 'adopt', service_id: 'srv-abcdefgh1234' });
151
+ const r = await post(PATH, { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'adopt', service_id: 'srv-abcdefgh1234' });
145
152
  assert.equal(r.status, 200, r.raw);
146
153
  assert.equal(enqueueCalls[0].appHost.serviceId, 'srv-abcdefgh1234');
154
+ assert.equal(enqueueCalls[0].appHost.runtime, undefined, 'a link records nothing about building');
155
+ });
156
+ await ta('docker needs no commands, and no database is the default', async () => {
157
+ reset();
158
+ const r = await post(PATH, { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'greenfield', runtime: 'docker' });
159
+ assert.equal(r.status, 200, r.raw);
160
+ assert.deepEqual([enqueueCalls[0].appHost.runtime, enqueueCalls[0].appHost.startCommand, enqueueCalls[0].appHost.database], ['docker', undefined, false]);
161
+ assert.doesNotMatch(r.body.message, /database/, 'the receipt does not promise a database');
147
162
  });
148
163
 
149
164
  console.log('\nrefusals keep nothing:');
@@ -187,13 +202,118 @@ await ta('one statement writes the intent with the ciphertext and merges the row
187
202
  });
188
203
  await ta('a collision on the open-intent index stores nothing and names the blocker', async () => {
189
204
  let n = 0;
190
- const db = { query: async () => { if (n++ === 0) throw Object.assign(new Error('dup'), { code: '23505' }); return { rows: [{ id: 3, action: 'teardown', state: 'pending' }] }; } };
205
+ const db = { query: async () => { const i = n++; if (i === 0) throw Object.assign(new Error('dup'), { code: '23505' }); if (i === 1) return { rows: [] }; return { rows: [{ id: 3, action: 'teardown', state: 'pending' }] }; } };
191
206
  const out = await realEnqueue(db, 18, { keyEnc: 'v1.x', appHost: { mode: 'greenfield', ownerId: 'tea-abcdefgh1234' } });
192
207
  assert.equal(out.conflict, 'open_intent');
193
208
  assert.equal(out.intent.action, 'teardown');
194
209
  });
210
+ await ta('a Create still being set up takes the key onto its OWN run (task 1004352)', async () => {
211
+ const sqls = []; let n = 0;
212
+ const db = { query: async (sql, params) => { sqls.push({ sql, params }); if (n++ === 0) throw Object.assign(new Error('dup'), { code: '23505' }); return { rows: [{ id: 7, action: 'provision', state: 'running' }] }; } };
213
+ const out = await realEnqueue(db, 18, { keyEnc: 'v1.x', appHost: { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234' } });
214
+ assert.equal(out.attached, true);
215
+ assert.equal(out.intent.action, 'provision');
216
+ assert.equal(out.conflict, undefined);
217
+ const attach = sqls[1].sql.replace(/\s+/g, ' ');
218
+ assert.match(attach, /action = 'provision' AND state IN \('pending', 'running'\) AND borrowed_key_enc IS NULL/, 'only an open provision run with no key yet');
219
+ assert.match(attach, /UPDATE provisioning_instances SET app_host = \$3::jsonb WHERE id = \$1 AND EXISTS \(SELECT 1 FROM i\)/, 'the row is touched only if the key landed');
220
+ assert.equal(sqls[1].params[1], 'v1.x');
221
+ });
195
222
  guard.assertIntentAllowed = realGuard;
196
223
 
224
+ console.log('\nPOST /provisioning/render/lookup — what the key can see (ADR 0348):');
225
+ const lookupApp = express();
226
+ lookupApp.use(express.json());
227
+ lookupApp.use((_req, res, next) => { res.fail = (code, opts) => res.status((opts && opts.status) || 400).json({ error: { code, ...(typeof opts === 'object' ? opts : {}) } }); next(); });
228
+ let renderCalls = [], renderAnswers = {};
229
+ const fakeRenderFetch = async (url, init) => {
230
+ renderCalls.push({ url, method: init.method, auth: init.headers.Authorization, body: init.body });
231
+ const path = url.replace('https://api.render.com/v1', '');
232
+ const a = renderAnswers[path];
233
+ if (a === undefined) return { ok: false, status: 404, json: async () => ({}) };
234
+ if (typeof a === 'number') return { ok: false, status: a, json: async () => ({}) };
235
+ return { ok: true, status: 200, json: async () => a };
236
+ };
237
+ lookupApp.use('/api/bongos', buildRouter({ fetchImpl: fakeRenderFetch }));
238
+ const lookupServer = lookupApp.listen(0);
239
+ await new Promise((r) => lookupServer.once('listening', r));
240
+ const lookupBase = `http://127.0.0.1:${lookupServer.address().port}/api/bongos/provisioning/render/lookup`;
241
+ const lookup = (body) => new Promise((resolve, reject) => {
242
+ const req = http.request(lookupBase, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
243
+ let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d || '{}'), raw: d }));
244
+ });
245
+ req.on('error', reject); req.end(JSON.stringify(body));
246
+ });
247
+ const TEA_A = 'tea-aaaaaaaa1111', TEA_B = 'tea-bbbbbbbb2222';
248
+ function resetLookup() {
249
+ reset(); renderCalls = [];
250
+ renderAnswers = {
251
+ '/owners?limit=100': [{ owner: { id: TEA_A, name: 'Mine', type: 'team' } }, { owner: { id: TEA_B, name: 'A friend\'s team', type: 'team' } }],
252
+ [`/services?ownerId=${TEA_A}&type=web_service&limit=100`]: [{ service: { id: 'srv-aaaaaaaa1111', name: 'shop', ownerId: TEA_A, repo: 'https://github.com/example-owner/spike', serviceDetails: { url: 'https://shop.onrender.com' } } }],
253
+ };
254
+ }
255
+ await ta('a key that reaches several workspaces lists them ALL, in Render\'s order, and picks none', async () => {
256
+ resetLookup();
257
+ const r = await lookup({ key: KEY });
258
+ assert.equal(r.status, 200, r.raw);
259
+ assert.deepEqual(r.body.workspaces.map((w) => w.id), [TEA_A, TEA_B]);
260
+ assert.equal(r.body.services, undefined, 'no workspace is chosen for the owner, so none is looked into');
261
+ assert.equal(renderCalls.length, 1);
262
+ assert.equal(renderCalls[0].auth, `Bearer ${KEY}`);
263
+ assert.ok(!renderCalls[0].url.includes(KEY), 'the key is never in a URL');
264
+ noKeyAnywhere(r.body);
265
+ });
266
+ await ta('a refused key says so, in words, and nothing else is called', async () => {
267
+ resetLookup(); renderAnswers['/owners?limit=100'] = 401;
268
+ const r = await lookup({ key: KEY });
269
+ assert.equal(r.status, 422, r.raw);
270
+ assert.equal(r.body.error.code, 'render_key_refused');
271
+ assert.match(r.body.error.message, /refused that key/);
272
+ assert.equal(renderCalls.length, 1);
273
+ noKeyAnywhere(r.body);
274
+ });
275
+ await ta('the chosen workspace lists its web services, each checked to be from that workspace', async () => {
276
+ resetLookup();
277
+ const r = await lookup({ key: KEY, owner_id: TEA_A });
278
+ assert.equal(r.status, 200, r.raw);
279
+ assert.deepEqual(r.body.services, [{ id: 'srv-aaaaaaaa1111', name: 'shop', repo: 'https://github.com/example-owner/spike', url: 'https://shop.onrender.com' }]);
280
+ });
281
+ await ta('a service from ANOTHER workspace in the answer shows nothing at all', async () => {
282
+ resetLookup();
283
+ renderAnswers[`/services?ownerId=${TEA_A}&type=web_service&limit=100`] = [{ service: { id: 'srv-x', name: 'theirs', ownerId: TEA_B } }];
284
+ const r = await lookup({ key: KEY, owner_id: TEA_A });
285
+ assert.equal(r.status, 502, r.raw);
286
+ assert.equal(r.body.error.code, 'render_owner_mismatch');
287
+ assert.equal(r.body.services, undefined);
288
+ });
289
+ await ta('a workspace the key cannot reach is refused before its services are asked for', async () => {
290
+ resetLookup();
291
+ const r = await lookup({ key: KEY, owner_id: 'tea-cccccccc3333' });
292
+ assert.equal(r.status, 422, r.raw);
293
+ assert.equal(renderCalls.length, 1);
294
+ });
295
+ await ta('the lookup only ever GETs', async () => {
296
+ resetLookup();
297
+ await lookup({ key: KEY }); await lookup({ key: KEY, owner_id: TEA_A });
298
+ assert.deepEqual([...new Set(renderCalls.map((c) => c.method))], ['GET']);
299
+ assert.ok(renderCalls.every((c) => !c.body));
300
+ const src = readFileSync(new URL('../modules/provisioning/render-lookup.js', import.meta.url), 'utf8');
301
+ assert.doesNotMatch(src, /method: '(POST|PUT|PATCH|DELETE)'/);
302
+ });
303
+ await ta('a malformed key or workspace never reaches Render', async () => {
304
+ resetLookup();
305
+ assert.equal((await lookup({ key: 'ghp_nope' })).status, 400);
306
+ assert.equal((await lookup({ key: KEY, owner_id: 'srv-aaaaaaaa1111' })).status, 400);
307
+ assert.equal(renderCalls.length, 0);
308
+ });
309
+ await ta('the lookup is capped per builder', async () => {
310
+ resetLookup(); builder = { id: 555, github_login: 'burst', rank: 'thetes' };
311
+ let last = null;
312
+ for (let i = 0; i < 21; i++) last = await lookup({ key: KEY });
313
+ assert.equal(last.status, 429);
314
+ });
315
+ lookupServer.close();
316
+
197
317
  console.log('\nwiring:');
198
318
  await ta('module.json mounts the route; the action is in the vocabulary and the newest CHECK', () => {
199
319
  const manifest = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
@@ -24,7 +24,8 @@ test('a shared asset reaches every page in its dir whose HTML names it', () => {
24
24
  // sky-data.js is shared by the two doors of one page (task 1004232): /thinking and /goal-map.
25
25
  assert.deepEqual(names(['modules/hall-ui/public/sky-data.js']), ['goal-map.states.json', 'thinking.states.json']);
26
26
  assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goals.states.json']);
27
- assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['projects.states.json']);
27
+ // the account page links the hub's sheet too since task 1004259
28
+ assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'projects.states.json']);
28
29
  });
29
30
 
30
31
  test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {