@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
@@ -59,7 +59,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
59
59
  // 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
60
60
  // 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
61
61
  // deploy door. The web tier only QUEUES it; the control-plane runner performs it.
62
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private'];
62
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private', 'render-standup'];
63
63
  // Every state a teardown can be asked for — i.e. everything except the one
64
64
  // state where there is provably nothing left to release. DERIVED from
65
65
  // INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
@@ -1168,9 +1168,9 @@ async function enqueueIntent(db, instanceId, action, requestedBy, targetVersion
1168
1168
  async function enqueueTeardown(db, instanceId, requestedBy) {
1169
1169
  const { rows: superseded } = await db.query(
1170
1170
  `UPDATE provisioning_intents
1171
- SET state = 'error', last_error = 'superseded: teardown requested',
1171
+ SET state = 'error', last_error = 'superseded: teardown requested', borrowed_key_enc = NULL,
1172
1172
  updated_at = now(), resolved_at = now()
1173
- WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private') AND state = 'pending'
1173
+ WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup') AND state = 'pending'
1174
1174
  AND NOT EXISTS (SELECT 1 FROM provisioning_instances WHERE id = $1 AND hosting_shape = 'control-plane') RETURNING id`,
1175
1175
  [instanceId]
1176
1176
  );
@@ -1248,8 +1248,8 @@ async function claimNextIntent(db) {
1248
1248
  async function resolveIntent(db, id, state, lastError) {
1249
1249
  await db.query(
1250
1250
  `UPDATE provisioning_intents
1251
- SET state = $2, last_error = $3, updated_at = now(), resolved_at = now()
1252
- WHERE id = $1`,
1251
+ SET state = $2, last_error = $3, borrowed_key_enc = NULL, updated_at = now(), resolved_at = now()
1252
+ WHERE id = $1`, // a resolved intent never keeps a borrowed key (task 1004183; the table's CHECK is the backstop)
1253
1253
  [id, state, lastError ?? null]
1254
1254
  );
1255
1255
  }
@@ -0,0 +1,68 @@
1
+ // modules/provisioning/render-lookup.js — the ONE Render call the web tier may make: a
2
+ // read-only look at what an owner's key can see (task 1004352; ADR 0348).
3
+ //
4
+ // The setup wizard has to tell the owner at once when a key is refused, and has to make
5
+ // them choose among the workspaces the key reaches (a Render key belongs to a USER and
6
+ // reaches every workspace that user is in — ADR 0327). Only a call to Render can answer
7
+ // either, and the owner is still on the page. So ADR 0348 makes one narrow exception to
8
+ // ADR 0111 §2 ("the web tier never calls a cloud API"), and this file is all of it:
9
+ //
10
+ // - GET only — two calls, the workspace list and the services in ONE named workspace.
11
+ // Nothing here can create, change or delete anything, and there is no way to ask it to.
12
+ // - the key is the owner's own, held in memory for this one request; it is never
13
+ // stored, logged or returned, and it is not encrypted here because it is not kept.
14
+ // - every service in the answer is asserted to carry the workspace asked about — the
15
+ // same guard the runner's client applies (scripts/gds/render-api.js). A module may
16
+ // not require that file (ADR 0083), so this carries its own two GETs rather than a
17
+ // second client.
18
+ //
19
+ // `fetchImpl` is injectable so tests never touch the network.
20
+ 'use strict';
21
+
22
+ const RENDER_API = 'https://api.render.com/v1';
23
+ const TIMEOUT_MS = 10_000;
24
+
25
+ class RenderLookupError extends Error {
26
+ constructor(code, message) { super(message); this.code = code; }
27
+ }
28
+
29
+ async function get(path, key, fetchImpl) {
30
+ let res;
31
+ try {
32
+ res = await fetchImpl(`${RENDER_API}${path}`, {
33
+ method: 'GET',
34
+ headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
35
+ signal: AbortSignal.timeout(TIMEOUT_MS),
36
+ });
37
+ } catch (_) {
38
+ throw new RenderLookupError('render_unreachable', 'Render didn’t answer. Try again in a moment.');
39
+ }
40
+ if (res.status === 401 || res.status === 403) {
41
+ throw new RenderLookupError('render_key_refused', 'Render refused that key. Check you copied all of it, and that it hasn’t been deleted.');
42
+ }
43
+ if (!res.ok) throw new RenderLookupError('render_unreachable', `Render answered ${res.status}. Try again in a moment.`);
44
+ try { return await res.json(); } catch (_) { throw new RenderLookupError('render_unreachable', 'Render sent an answer we couldn’t read.'); }
45
+ }
46
+
47
+ const unwrap = (json, field) => (Array.isArray(json) ? json : []).map((e) => (e && e[field]) || e).filter((o) => o && typeof o.id === 'string');
48
+
49
+ // What the key can see: every workspace, and — when `ownerId` names one of them — the
50
+ // web services in it. Throws RenderLookupError with a code the route maps to a reply.
51
+ async function lookupRender(key, { ownerId = null, fetchImpl = globalThis.fetch } = {}) {
52
+ const workspaces = unwrap(await get('/owners?limit=100', key, fetchImpl), 'owner')
53
+ .map((o) => ({ id: o.id, name: String(o.name || o.email || o.id), type: o.type === 'user' ? 'user' : 'team' }));
54
+ if (!ownerId) return { workspaces };
55
+ if (!workspaces.some((w) => w.id === ownerId)) {
56
+ throw new RenderLookupError('render_workspace_unreachable', 'That key can’t reach the workspace you chose.');
57
+ }
58
+ const services = unwrap(await get(`/services?ownerId=${encodeURIComponent(ownerId)}&type=web_service&limit=100`, key, fetchImpl), 'service')
59
+ .map((s) => {
60
+ if (s.ownerId !== ownerId) {
61
+ throw new RenderLookupError('render_owner_mismatch', 'Render answered with a service from another workspace, so nothing was shown.');
62
+ }
63
+ return { id: s.id, name: String(s.name || s.id), repo: s.repo || null, url: (s.serviceDetails && s.serviceDetails.url) || null };
64
+ });
65
+ return { workspaces, services };
66
+ }
67
+
68
+ module.exports = { lookupRender, RenderLookupError, RENDER_API };
@@ -0,0 +1,134 @@
1
+ // modules/provisioning/render-standup.js — the queue half of "set this project up on my
2
+ // Render account" (task 1004183; ADR 0327, ADR 0345).
3
+ //
4
+ // The web tier never calls Render (ADR 0111 §2): it checks the SHAPE of what the owner
5
+ // sent, encrypts their key, and queues a `render-standup` intent carrying it. The runner
6
+ // leg, scripts/gds/provision-render.js, spends the key in one drain and clears it.
7
+ //
8
+ // What the encryption buys, said plainly: it protects the key from a database dump or a
9
+ // nightly backup while the intent waits for the runner. It does NOT defend against a
10
+ // compromised web tier — the key arrives here in plaintext before anything can encrypt
11
+ // it. Nothing here stores the key anywhere else, logs it, or sends it back.
12
+ //
13
+ // A PROJECT STILL BEING SET UP. The wizard sends the key right after Create, while the
14
+ // project's own `provision` run holds the one open-intent slot. Rather than refuse, the key
15
+ // rides THAT run (task 1004352): the runner sets the app up on Render straight after the
16
+ // project is up, in the same drain, and clears the key either way
17
+ // (scripts/gds/provision-render.js afterProvision).
18
+ //
19
+ // Its own file because provisioning.js sits at the size ratchet.
20
+ 'use strict';
21
+
22
+ // The plan and region vocabulary — shared with the runner's payload builder, which
23
+ // requires it from here, so the web tier and the runner can never disagree on a name.
24
+ const WEB_PLANS = ['starter', 'standard', 'pro', 'pro_plus', 'pro_max', 'pro_ultra'];
25
+ const DB_PLANS = ['basic_256mb', 'basic_1gb', 'basic_4gb', 'pro_4gb', 'pro_8gb'];
26
+ const REGIONS = ['virginia', 'ohio', 'oregon', 'frankfurt', 'singapore'];
27
+ const MODES = ['adopt', 'greenfield'];
28
+ // How the owner's app is built (task 1004350): a native runtime with their commands, or
29
+ // `docker`, which builds the repository's own Dockerfile.
30
+ const RUNTIMES = ['node', 'python', 'ruby', 'go', 'rust', 'elixir', 'docker'];
31
+
32
+ // Render API keys start `rnd_`; workspaces are `tea-…` (a team) or `usr-…` (a personal
33
+ // one); services `srv-…`. Shape checks only — whether the key works, and whether it can
34
+ // reach the workspace, is the runner's to find out, because only the runner calls Render.
35
+ const KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
36
+ const OWNER_RE = /^(tea|usr)-[a-z0-9]{8,64}$/;
37
+ const SERVICE_RE = /^srv-[a-z0-9]{8,64}$/;
38
+
39
+ // The reason a request cannot be queued, in the owner's words, or null. `body` is the
40
+ // validated request body.
41
+ function badRequest(body) {
42
+ const b = body || {};
43
+ if (!KEY_RE.test(String(b.key || ''))) return 'That doesn’t look like a Render API key. It starts with “rnd_” — create one under Account Settings → API Keys in Render.';
44
+ if (!OWNER_RE.test(String(b.owner_id || ''))) return 'Choose the Render workspace to use. Its id starts with “tea-” or “usr-”.';
45
+ if (!MODES.includes(b.mode)) return 'Say whether to link a Render service you already have, or create a new one.';
46
+ if (b.mode === 'adopt' && !SERVICE_RE.test(String(b.service_id || ''))) return 'Choose the Render service to link. Its id starts with “srv-”.';
47
+ if (b.mode === 'greenfield' && b.service_id) return 'A new setup creates its own service, so leave the service out.';
48
+ if (b.mode === 'adopt' && (b.runtime != null || b.build_command != null || b.start_command != null || b.database != null)) return 'Linking keeps your Render service exactly as it is, so leave out how it is built.';
49
+ if (b.mode === 'greenfield') {
50
+ if (!RUNTIMES.includes(b.runtime)) return 'Say what your app is built with — for example Node or Python, or Docker to use your repository’s Dockerfile.';
51
+ if (b.runtime === 'docker' && (b.build_command || b.start_command)) return 'Docker builds and starts your app from your Dockerfile, so leave the commands out.';
52
+ if (b.runtime !== 'docker' && !String(b.start_command || '').trim()) return 'Say how your app starts — the command Render should run, for example “npm start”.';
53
+ }
54
+ if (b.web_plan != null && !WEB_PLANS.includes(b.web_plan)) return `“${b.web_plan}” is not a Render web plan.`;
55
+ if (b.db_plan != null && !DB_PLANS.includes(b.db_plan)) return `“${b.db_plan}” is not a Render database plan.`;
56
+ if (b.region != null && !REGIONS.includes(b.region)) return `“${b.region}” is not a Render region.`;
57
+ if (b.db_plan != null && b.database !== true) return 'A database plan needs a database — ask for one, or leave the plan out.';
58
+ return null;
59
+ }
60
+
61
+ // What the row records about the request — ids and plans, never the key.
62
+ function appHostFrom(body) {
63
+ const h = { platform: 'render', mode: body.mode, ownerId: body.owner_id, state: 'requested', error: null };
64
+ if (body.mode === 'adopt') h.serviceId = body.service_id;
65
+ if (body.mode === 'greenfield') {
66
+ h.runtime = body.runtime;
67
+ if (body.runtime !== 'docker') { h.startCommand = body.start_command.trim(); if (body.build_command) h.buildCommand = body.build_command.trim(); }
68
+ h.database = body.database === true;
69
+ }
70
+ if (body.web_plan) h.webPlan = body.web_plan;
71
+ if (body.db_plan) h.dbPlan = body.db_plan;
72
+ if (body.region) h.region = body.region;
73
+ return h;
74
+ }
75
+
76
+ // Queue the standup with the encrypted key, and record the request on the row, in ONE
77
+ // statement: either both land or neither does. A re-request for the same workspace and
78
+ // mode keeps the ids an earlier run recorded, so the runner resumes onto what already
79
+ // exists; a different workspace or mode starts clean, because ids from another workspace
80
+ // would only trip the runner's guard.
81
+ //
82
+ // The partial unique index ux_provisioning_intents_open is the arbiter: if anything is
83
+ // already open for this project the insert collides and NOTHING is written — the key is
84
+ // not stored and the row is not touched. Returns { intent } or { conflict: 'open_intent',
85
+ // intent } with the blocking one.
86
+ async function enqueueRenderStandup(db, instanceId, { keyEnc, appHost, requestedBy }) {
87
+ await require('./control-plane-guard').assertIntentAllowed(db, instanceId, 'render-standup');
88
+ try {
89
+ const { rows } = await db.query(
90
+ `WITH i AS (
91
+ INSERT INTO provisioning_intents (instance_id, action, requested_by, state, borrowed_key_enc)
92
+ VALUES ($1, 'render-standup', $2, 'pending', $3) RETURNING *
93
+ ), u AS (
94
+ UPDATE provisioning_instances
95
+ SET app_host = CASE WHEN app_host->>'ownerId' = $5 AND app_host->>'mode' = $6 THEN app_host ELSE '{}'::jsonb END || $4::jsonb
96
+ WHERE id = $1 RETURNING id
97
+ )
98
+ SELECT i.id, i.instance_id, i.action, i.state, i.created_at FROM i`,
99
+ [instanceId, requestedBy ?? null, keyEnc, JSON.stringify(appHost), appHost.ownerId, appHost.mode]);
100
+ return { intent: rows[0] };
101
+ } catch (e) {
102
+ if (e && e.code === '23505') {
103
+ const attached = await attachToProvision(db, instanceId, { keyEnc, appHost });
104
+ if (attached) return { intent: attached, attached: true };
105
+ const { rows } = await db.query(
106
+ `SELECT id, action, state FROM provisioning_intents
107
+ WHERE instance_id = $1 AND state IN ('pending', 'running') ORDER BY created_at DESC LIMIT 1`, [instanceId]);
108
+ return { conflict: 'open_intent', intent: rows[0] || { action: 'unknown', state: 'open' } };
109
+ }
110
+ throw e;
111
+ }
112
+ }
113
+
114
+ // Put the key on the project's OPEN provision run, and record the request on the row, in
115
+ // one statement. Only a run that holds no key yet: a second request never overwrites the
116
+ // first. Returns the intent, or null when there is no such run (the caller then reports
117
+ // the conflict as usual). The table's CHECK still refuses a key on a resolved intent, so
118
+ // a run that finished a moment ago cannot be handed one.
119
+ async function attachToProvision(db, instanceId, { keyEnc, appHost }) {
120
+ const { rows } = await db.query(
121
+ `WITH i AS (
122
+ UPDATE provisioning_intents SET borrowed_key_enc = $2, updated_at = now()
123
+ WHERE instance_id = $1 AND action = 'provision' AND state IN ('pending', 'running') AND borrowed_key_enc IS NULL
124
+ RETURNING id, instance_id, action, state, created_at
125
+ ), u AS (
126
+ UPDATE provisioning_instances SET app_host = $3::jsonb
127
+ WHERE id = $1 AND EXISTS (SELECT 1 FROM i) RETURNING id
128
+ )
129
+ SELECT * FROM i`,
130
+ [instanceId, keyEnc, JSON.stringify(appHost)]);
131
+ return rows[0] || null;
132
+ }
133
+
134
+ module.exports = { attachToProvision, KEY_RE, OWNER_RE, WEB_PLANS, DB_PLANS, REGIONS, MODES, RUNTIMES, badRequest, appHostFrom, enqueueRenderStandup };
@@ -0,0 +1,165 @@
1
+ // modules/provisioning/routes/render-standup.js — POST /provisioning/instances/:id/render
2
+ // (task 1004183; ADR 0327, ADR 0345).
3
+ //
4
+ // The owner lends their Render API key once, names the workspace, and says whether to link
5
+ // a service they already run for this repository (adopt) or create a new one for their app
6
+ // (greenfield: how it is built and started, and whether it needs a database — task 1004350). This route checks
7
+ // the shape, encrypts the key with the secret box, and queues a `render-standup` intent;
8
+ // the runner (scripts/gds/provision-render.js) spends the key in one drain and clears it.
9
+ // This process never calls Render (ADR 0111 §2).
10
+ //
11
+ // What the encryption buys, said plainly: protection from a database dump or a backup
12
+ // while the intent waits. Not from a compromised web tier — the key is in plaintext here
13
+ // before it is encrypted. The key is never logged, never stored anywhere else, and never
14
+ // sent back.
15
+ //
16
+ // Its own routes file (declared in module.json) because routes/provisioning.js sits at the
17
+ // size ratchet — the routes/repo-private.js precedent.
18
+ 'use strict';
19
+
20
+ const express = require('express');
21
+ const api = require('../../../src/module-api');
22
+ const { pool, validateOrRespond, parseId } = api;
23
+ const provisioning = require('../provisioning');
24
+ const renderStandup = require('../render-standup');
25
+ const { failFrom } = require('../public-refusal');
26
+ const { lookupRender, RenderLookupError } = require('../render-lookup');
27
+ const { appStatus } = require('../app-status');
28
+ const envManifest = require('../env-manifest');
29
+ const { makeAskRateLimiter } = require('../rate-limit');
30
+
31
+ const log = api.logger('provisioning');
32
+
33
+ // What the runner leg would refuse about the PROJECT, answered here so the owner hears it
34
+ // before lending a key. The runner keeps its own checks.
35
+ function unavailableReason(inst, mode) {
36
+ if (inst.status === 'torn_down' || inst.status === 'tearing_down') return 'This project has been removed, so it can’t be set up on Render.';
37
+ if (!inst.target_ref) return mode === 'adopt' ? 'This project has no GitHub repository linked, so there is no app to check the Render service against.' : 'This project has no GitHub repository linked, so Render has nothing to build.';
38
+ return null;
39
+ }
40
+
41
+ // The lookup is a Render call made on the owner's behalf, so it is capped per builder:
42
+ // a wizard needs a handful (check the key, then one workspace or two), never a stream.
43
+ const LOOKUP_STATUS = { render_key_refused: 422, render_workspace_unreachable: 422, render_owner_mismatch: 502, render_unreachable: 502 };
44
+
45
+ // OWNER ONLY, no archon override, for every Render route: the key and the account it acts
46
+ // in are the owner's. A missing project and someone else's answer the same 404, so an id is
47
+ // never confirmed.
48
+ function ownedBy(inst, builder) { return !!(inst && builder && String(inst.owner_builder_id) === String(builder.id)); }
49
+
50
+ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
51
+ const router = express.Router();
52
+ const lookupLimit = makeAskRateLimiter({ windowMs: 60_000, max: 20, key: (req) => `b:${req.builder && req.builder.id}` });
53
+ const statusLimit = makeAskRateLimiter({ windowMs: 60_000, max: 60, key: (req) => `s:${req.builder && req.builder.id}` });
54
+
55
+ // POST /provisioning/render/lookup — rank: any authenticated builder. What the owner's
56
+ // Render key can see: its workspaces, and the web services in the one they name. The
57
+ // ONLY Render call this process makes (ADR 0348): GET only, the key held for this
58
+ // request and never stored, logged or sent back. POST so the key travels in a body,
59
+ // never a URL.
60
+ router.post('/provisioning/render/lookup', api.requireBuilder, lookupLimit, async (req, res) => {
61
+ if (validateOrRespond(req, res, {
62
+ key: { required: true, type: 'string', maxLength: 200 },
63
+ owner_id: { type: 'string', maxLength: 80 },
64
+ })) return;
65
+ if (!renderStandup.KEY_RE.test(req.body.key)) {
66
+ return res.fail('render_request_invalid', { status: 400, message: 'That doesn’t look like a Render API key. It starts with “rnd_”.' });
67
+ }
68
+ if (req.body.owner_id != null && !renderStandup.OWNER_RE.test(req.body.owner_id)) {
69
+ return res.fail('render_request_invalid', { status: 400, message: 'That isn’t a Render workspace id.' });
70
+ }
71
+ try {
72
+ const out = await lookupRender(req.body.key, { ownerId: req.body.owner_id || null, fetchImpl: fetchImpl || globalThis.fetch });
73
+ res.json({ ok: true, ...out });
74
+ } catch (err) {
75
+ if (err instanceof RenderLookupError) return res.fail(err.code, { status: LOOKUP_STATUS[err.code] || 502, message: err.message });
76
+ log.error('[provisioning] POST /provisioning/render/lookup', err && err.message);
77
+ failFrom(res, err, 'render_lookup_failed');
78
+ }
79
+ });
80
+
81
+ // GET /provisioning/instances/:id/render — rank: any authenticated builder, and OWNER
82
+ // ONLY, like the POST (task 1004353). Where the project's app setup on Render stands:
83
+ // the stage, the app's address, the Render links, a failure as one sentence with the
84
+ // answers a Retry re-sends, and the env var NAMES still to set there (task 1004062's
85
+ // manifest — never a value; this platform holds none). Read from the row alone: nothing
86
+ // here calls Render, and there is no key to hold. `app` is null when the project's app
87
+ // has no platform chosen.
88
+ router.get('/provisioning/instances/:id/render', api.requireBuilder, statusLimit, async (req, res) => {
89
+ const id = parseId(req, res, { positiveInt: true });
90
+ if (!id) return;
91
+ try {
92
+ const inst = await provisioning.getInstanceById(pool, id);
93
+ if (!ownedBy(inst, req.builder)) return res.fail('instance_not_found', 404);
94
+ // A run that still carries the lent key has not spent it yet: the owner waits.
95
+ const { rows } = await pool.query(
96
+ `SELECT EXISTS (SELECT 1 FROM provisioning_intents WHERE instance_id = $1 AND state IN ('pending', 'running') AND borrowed_key_enc IS NOT NULL) AS waiting`,
97
+ [id]);
98
+ const app = appStatus(inst.app_host, { keyWaiting: !!(rows[0] && rows[0].waiting) });
99
+ const dbSet = !!(inst.app_host && inst.app_host.database === true);
100
+ const envVars = app && app.state !== 'disconnected'
101
+ ? (await envManifest.listEnvVars(pool, id)).map((v) => ({ name: v.name, required: v.required !== false, set_for_you: dbSet && v.name === 'DATABASE_URL' }))
102
+ : [];
103
+ res.json({ ok: true, app, env_vars: envVars, values_held: false });
104
+ } catch (err) {
105
+ log.error('[provisioning] GET /provisioning/instances/:id/render', err && err.message);
106
+ failFrom(res, err, 'render_status_failed');
107
+ }
108
+ });
109
+
110
+ // POST /provisioning/instances/:id/render — rank: any authenticated builder, and OWNER
111
+ // ONLY: the key is the owner's and so is the account it acts in, so an archon on
112
+ // someone else's project gets the same 404 as a stranger. The reply is a receipt;
113
+ // nothing has happened on Render when it is sent.
114
+ router.post('/provisioning/instances/:id/render', api.requireBuilder, async (req, res) => {
115
+ const id = parseId(req, res, { positiveInt: true });
116
+ if (!id) return;
117
+ if (validateOrRespond(req, res, {
118
+ key: { required: true, type: 'string', maxLength: 200 },
119
+ owner_id: { required: true, type: 'string', maxLength: 80 },
120
+ mode: { required: true, type: 'string', maxLength: 20 },
121
+ service_id: { type: 'string', maxLength: 80 },
122
+ web_plan: { type: 'string', maxLength: 40 },
123
+ db_plan: { type: 'string', maxLength: 40 },
124
+ region: { type: 'string', maxLength: 40 },
125
+ runtime: { type: 'string', maxLength: 20 },
126
+ build_command: { type: 'string', maxLength: 500 },
127
+ start_command: { type: 'string', maxLength: 500 },
128
+ database: { type: 'boolean' },
129
+ })) return;
130
+ try {
131
+ const inst = await provisioning.getInstanceById(pool, id);
132
+ if (!ownedBy(inst, req.builder)) return res.fail('instance_not_found', 404);
133
+ const bad = renderStandup.badRequest(req.body);
134
+ if (bad) return res.fail('render_request_invalid', { status: 400, message: bad });
135
+ const why = unavailableReason(inst, req.body.mode);
136
+ if (why) return res.fail('render_unavailable', { status: 409, message: why, details: { status: inst.status } });
137
+ if (!api.secretBox.isConfigured()) {
138
+ return res.fail('render_unavailable', { status: 503, message: 'The platform can’t take a key safely right now, so nothing was sent. Try again later.' });
139
+ }
140
+ const appHost = renderStandup.appHostFrom(req.body);
141
+ const r = await renderStandup.enqueueRenderStandup(pool, id, {
142
+ keyEnc: api.secretBox.encrypt(req.body.key), appHost, requestedBy: 'api:self',
143
+ });
144
+ if (r.conflict) {
145
+ return res.fail('intent_conflict', { status: 409,
146
+ message: `A '${r.intent.action}' run is ${r.intent.state} for this project, so your key was not kept. Try again once it has finished.`,
147
+ details: { action: r.intent.action, state: r.intent.state } });
148
+ }
149
+ await provisioning.recordEvent(pool, {
150
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'render-standup',
151
+ detail: `Render ${appHost.mode === 'adopt' ? 'link' : 'setup'} requested by owner (workspace ${appHost.ownerId})`, actor: 'api:self',
152
+ }).catch(() => {});
153
+ res.json({ ok: true, queued: true, action: 'render-standup', mode: appHost.mode, with_setup: !!r.attached,
154
+ message: appHost.mode === 'adopt'
155
+ ? 'Queued. The platform will check the service in your Render account and link it, without changing anything there. Your key is used once and then thrown away.'
156
+ : `Queued. The platform will create ${appHost.database ? 'a database and ' : ''}a web service for your app in your Render account. Your key is used once and then thrown away; Render bills you directly.` });
157
+ } catch (err) {
158
+ // Never log the request: it carries the key.
159
+ log.error('[provisioning] POST /provisioning/instances/:id/render', err && err.message);
160
+ failFrom(res, err, 'render_standup_failed');
161
+ }
162
+ });
163
+
164
+ return router;
165
+ };
@@ -73,7 +73,10 @@ const ALWAYS_ON_CORE = Object.freeze([
73
73
  // the hub creates receives those releases, so none is offered the publisher's page. It
74
74
  // is package-agnostic by construction, so offering it to a project that publishes its
75
75
  // own npm package is a later, deliberate removal from this list — with picker copy.
76
- const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release']);
76
+ // `render-deploy` (task 1004270) is the deploy page of a project whose app deploys through
77
+ // Render: it is turned on by the connect-Render step, never chosen in this picker, so it is
78
+ // kept out of the opt-in catalog by the same list.
79
+ const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy']);
77
80
 
78
81
  // The OPT-IN catalog as the creation picker renders it (BV1.R20, task 1002339):
79
82
  // every module an owner may actually choose, in the order the picker lists them,
@@ -0,0 +1,14 @@
1
+ {
2
+ "_": "The account page for a private account with its record hidden and no published profile yet (task 1004259): the recruiter copy shrinks to name and picture and says it is the one exception to 'not found', the record line says nothing has been reported and that the profile is not published, and the empty profile fields show their placeholders. stub.fixtures points the account read at fixtures/me-cross-project-private.json.",
3
+ "page": "/account",
4
+ "surface": "public-landing",
5
+ "stub": { "fixtures": { "me__cross-project": "me-cross-project-private" } },
6
+ "modes": ["dark"],
7
+ "modeQuery": false,
8
+ "ignoreRequests": ["/api/(gds|bongos)/me/cross-project$"],
9
+ "ignoreConsole": ["/api/(gds|bongos)/me/cross-project", "\\b401\\b", "Unauthorized"],
10
+ "ignoreControls": [".vh"],
11
+ "states": {
12
+ "in-private": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#acctBody", "#recWhat"], "checked": "input[name=visibility][value=private]", "value": [["#fName", ""], ["#fBio", ""]], "text": [["#recordLine", "No cross-project activity reported yet. Your profile is not published yet; it publishes after your next sign-in."]] } }
13
+ }
14
+ }