@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
@@ -0,0 +1,336 @@
1
+ // tests/provisioning_render_route.mjs — POST /provisioning/instances/:id/render and its
2
+ // queue seam (task 1004183; ADR 0327).
3
+ //
4
+ // The load-bearing assertions:
5
+ // • OWNER ONLY: the key and the Render account are the owner's, so an archon on someone
6
+ // else's project gets the same 404 as a stranger, and nothing is encrypted or queued
7
+ // • only CIPHERTEXT reaches the queue — the plaintext key is never passed on, never in
8
+ // the reply, never in the audit event
9
+ // • the shape is checked before anything is kept; the web tier makes no Render call
10
+ // • no secret box → 503, and the key is not kept
11
+ // • a conflicting open intent → 409, and the key is not kept
12
+ // • the queue seam writes the intent AND the row in ONE statement, so a collision on
13
+ // the open-intent index stores nothing at all
14
+ //
15
+ // DB-free: data access is stubbed on the cached module objects, over a real mounted
16
+ // router. No network beyond localhost. Run: node tests/provisioning_render_route.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+ import { readFileSync } from 'node:fs';
21
+ import http from 'node:http';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const provisioning = require('../modules/provisioning/provisioning.js');
25
+ const renderStandup = require('../modules/provisioning/render-standup.js');
26
+ const secretBox = require('../src/bongos/secret-box.js');
27
+ const guard = require('../modules/provisioning/control-plane-guard.js');
28
+
29
+ let passed = 0, failed = 0;
30
+ async function ta(name, fn) {
31
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
32
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
33
+ }
34
+
35
+ const KEY = 'rnd_abcdefghijklmnopqrstuvwx';
36
+ let instance = null, enqueueCalls = [], enqueueResult = null, events = [], boxOn = true;
37
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
38
+ provisioning.recordEvent = async (_db, e) => { events.push(e); };
39
+ const realEnqueue = renderStandup.enqueueRenderStandup;
40
+ renderStandup.enqueueRenderStandup = async (_db, id, args) => {
41
+ enqueueCalls.push({ id, ...args });
42
+ return enqueueResult || { intent: { id: 9, action: 'render-standup', state: 'pending' } };
43
+ };
44
+ secretBox.isConfigured = () => boxOn;
45
+ secretBox.encrypt = (plain) => `v1.sealed(${plain.length})`;
46
+
47
+ const api = require('../src/module-api.js');
48
+ let builder = null;
49
+ api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
50
+ api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
51
+
52
+ const express = require('express');
53
+ const buildRouter = require('../modules/provisioning/routes/render-standup.js');
54
+ const app = express();
55
+ app.use(express.json());
56
+ app.use((_req, res, next) => {
57
+ res.fail = (code, opts, details) => {
58
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
59
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(Array.isArray(details) ? { details } : {}) } });
60
+ };
61
+ next();
62
+ });
63
+ app.use('/api/bongos', buildRouter());
64
+ const server = app.listen(0);
65
+ await new Promise((r) => server.once('listening', r));
66
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
67
+
68
+ function post(path, body) {
69
+ return new Promise((resolve, reject) => {
70
+ const payload = JSON.stringify(body);
71
+ const req = http.request(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
72
+ let d = '';
73
+ res.on('data', (c) => { d += c; });
74
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
75
+ });
76
+ req.on('error', reject);
77
+ req.end(payload);
78
+ });
79
+ }
80
+
81
+ const LIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', target_ref: 'example-owner/spike' };
82
+ function reset(patch = {}) {
83
+ instance = { ...LIVE, ...patch };
84
+ enqueueCalls = []; enqueueResult = null; events = []; boxOn = true;
85
+ builder = { id: 7, github_login: 'owner', rank: 'thetes' };
86
+ }
87
+ const PATH = '/provisioning/instances/18/render';
88
+ const GREEN = { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'greenfield', runtime: 'node', start_command: 'npm start' };
89
+ const noKeyAnywhere = (...xs) => xs.forEach((x) => assert.ok(!JSON.stringify(x).includes(KEY), 'the plaintext key leaked: ' + JSON.stringify(x)));
90
+
91
+ console.log('who may ask:');
92
+ await ta('the owner queues it: ciphertext only, the request recorded on the row, one audit event', async () => {
93
+ reset();
94
+ const r = await post(PATH, { ...GREEN, web_plan: 'standard', build_command: ' npm ci ', database: true });
95
+ assert.equal(r.status, 200, r.raw);
96
+ assert.equal(enqueueCalls.length, 1);
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,
99
+ runtime: 'node', startCommand: 'npm start', buildCommand: 'npm ci', database: true, webPlan: 'standard' });
100
+ assert.equal(events.length, 1);
101
+ noKeyAnywhere(enqueueCalls, events, r.body);
102
+ });
103
+ await ta('an ARCHON on someone else\'s project gets 404 — nothing encrypted, queued or recorded', async () => {
104
+ reset();
105
+ builder = { id: 42, github_login: 'arch', rank: 'archon' };
106
+ let sealed = 0; const enc = secretBox.encrypt; secretBox.encrypt = (p) => { sealed++; return enc(p); };
107
+ const r = await post(PATH, GREEN);
108
+ secretBox.encrypt = enc;
109
+ assert.equal(r.status, 404, r.raw);
110
+ assert.equal(sealed, 0);
111
+ assert.equal(enqueueCalls.length, 0);
112
+ assert.equal(events.length, 0);
113
+ });
114
+ await ta('a missing project is 404', async () => {
115
+ reset(); instance = null;
116
+ assert.equal((await post(PATH, GREEN)).status, 404);
117
+ });
118
+
119
+ console.log('\nthe shape is checked first:');
120
+ for (const [name, body, re] of [
121
+ ['a key that is not a Render key', { ...GREEN, key: 'ghp_notrender' }, /Render API key/],
122
+ ['a workspace id of the wrong shape', { ...GREEN, owner_id: 'srv-abcdefgh1234' }, /workspace/],
123
+ ['an unknown mode', { ...GREEN, mode: 'migrate' }, /link a Render service/],
124
+ ['adopt with no service', { ...GREEN, mode: 'adopt' }, /service to link/],
125
+ ['greenfield naming a service', { ...GREEN, service_id: 'srv-abcdefgh1234' }, /leave the service out/],
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/],
133
+ ]) {
134
+ await ta(`${name} → 400, nothing kept`, async () => {
135
+ reset();
136
+ const r = await post(PATH, body);
137
+ assert.equal(r.status, 400, r.raw);
138
+ assert.match(r.body.error.message, re);
139
+ assert.equal(enqueueCalls.length, 0);
140
+ noKeyAnywhere(r.body);
141
+ });
142
+ }
143
+ await ta('an unknown field is refused by the strict validator', async () => {
144
+ reset();
145
+ const r = await post(PATH, { ...GREEN, value: 'x' });
146
+ assert.equal(r.status, 400, r.raw);
147
+ assert.equal(enqueueCalls.length, 0);
148
+ });
149
+ await ta('adopt with a service id is accepted and records it', async () => {
150
+ reset();
151
+ const r = await post(PATH, { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'adopt', service_id: 'srv-abcdefgh1234' });
152
+ assert.equal(r.status, 200, r.raw);
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');
162
+ });
163
+
164
+ console.log('\nrefusals keep nothing:');
165
+ await ta('greenfield with no repository → 409', async () => {
166
+ reset({ target_ref: null });
167
+ const r = await post(PATH, GREEN);
168
+ assert.equal(r.status, 409, r.raw);
169
+ assert.equal(enqueueCalls.length, 0);
170
+ });
171
+ await ta('a removed project → 409', async () => {
172
+ reset({ status: 'torn_down' });
173
+ assert.equal((await post(PATH, GREEN)).status, 409);
174
+ assert.equal(enqueueCalls.length, 0);
175
+ });
176
+ await ta('no secret box → 503, and the key is not kept', async () => {
177
+ reset(); boxOn = false;
178
+ const r = await post(PATH, GREEN);
179
+ assert.equal(r.status, 503, r.raw);
180
+ assert.equal(enqueueCalls.length, 0);
181
+ });
182
+ await ta('something already open → 409 saying the key was not kept', async () => {
183
+ reset(); enqueueResult = { conflict: 'open_intent', intent: { action: 'provision', state: 'running' } };
184
+ const r = await post(PATH, GREEN);
185
+ assert.equal(r.status, 409, r.raw);
186
+ assert.match(r.body.error.message, /not kept/);
187
+ assert.equal(events.length, 0);
188
+ });
189
+
190
+ console.log('\nthe queue seam:');
191
+ const realGuard = guard.assertIntentAllowed;
192
+ guard.assertIntentAllowed = async () => {};
193
+ await ta('one statement writes the intent with the ciphertext and merges the row', async () => {
194
+ const sqls = [];
195
+ const db = { query: async (sql, params) => { sqls.push({ sql, params }); return { rows: [{ id: 5, action: 'render-standup', state: 'pending' }] }; } };
196
+ const out = await realEnqueue(db, 18, { keyEnc: 'v1.x', appHost: { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234' }, requestedBy: 'api:self' });
197
+ assert.equal(out.intent.id, 5);
198
+ assert.equal(sqls.length, 1, 'a single statement — both land or neither does');
199
+ assert.match(sqls[0].sql, /INSERT INTO provisioning_intents[\s\S]*borrowed_key_enc[\s\S]*UPDATE provisioning_instances[\s\S]*app_host/);
200
+ assert.equal(sqls[0].params[2], 'v1.x');
201
+ assert.doesNotMatch(sqls[0].sql, /RETURNING \*[\s\S]*SELECT i\.\*/, 'the ciphertext is not read back');
202
+ });
203
+ await ta('a collision on the open-intent index stores nothing and names the blocker', async () => {
204
+ let n = 0;
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' }] }; } };
206
+ const out = await realEnqueue(db, 18, { keyEnc: 'v1.x', appHost: { mode: 'greenfield', ownerId: 'tea-abcdefgh1234' } });
207
+ assert.equal(out.conflict, 'open_intent');
208
+ assert.equal(out.intent.action, 'teardown');
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
+ });
222
+ guard.assertIntentAllowed = realGuard;
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
+
317
+ console.log('\nwiring:');
318
+ await ta('module.json mounts the route; the action is in the vocabulary and the newest CHECK', () => {
319
+ const manifest = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
320
+ assert.ok(manifest.contributes.routes.includes('render-standup'));
321
+ assert.ok(provisioning.INTENT_ACTIONS.includes('render-standup'));
322
+ const mig = readFileSync(new URL('../modules/provisioning/migrations/provisioning_028_render_standup.sql', import.meta.url), 'utf8');
323
+ assert.match(mig, /borrowed_key_enc IS NULL OR state IN \('pending', 'running'\)/, 'a resolved intent cannot keep a key');
324
+ });
325
+ await ta('every path that resolves an intent clears the key', () => {
326
+ const src = readFileSync(new URL('../modules/provisioning/provisioning.js', import.meta.url), 'utf8');
327
+ const resolve = src.slice(src.indexOf('async function resolveIntent'), src.indexOf('async function resolveIntent') + 400);
328
+ assert.match(resolve, /borrowed_key_enc = NULL/);
329
+ const teardown = src.slice(src.indexOf('async function enqueueTeardown'), src.indexOf('async function enqueueTeardown') + 600);
330
+ assert.match(teardown, /borrowed_key_enc = NULL/, 'a superseded standup drops its key');
331
+ assert.match(teardown, /'render-standup'\) AND state = 'pending'/);
332
+ });
333
+
334
+ server.close();
335
+ console.log(`\nprovisioning_render_route: ${passed} passed, ${failed} failed`);
336
+ process.exit(failed ? 1 : 0);
@@ -169,7 +169,7 @@ await ta('enqueueTeardown supersedes a PENDING settings push (a push into a proj
169
169
  const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
170
170
  assert.equal(r.created, true);
171
171
  assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
172
- assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
172
+ assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
173
173
  });
174
174
 
175
175
  // ── the route, over a real mounted router (stubbed data access) ─────────────────
@@ -31,7 +31,7 @@ const require = createRequire(import.meta.url);
31
31
  const provisioning = require('../modules/provisioning/provisioning.js');
32
32
 
33
33
  // The actions a teardown supersedes, mirroring enqueueTeardown's SQL.
34
- const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private']);
34
+ const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private', 'render-standup']);
35
35
 
36
36
  let passed = 0, failed = 0;
37
37
  function t(name, fn) {
@@ -81,7 +81,7 @@ function fakeIntentDb(seed = [], { shape = 'co-tenant' } = {}) {
81
81
  // dead work in front of a teardown (task 1003140 widened it to the settings
82
82
  // push, task 1003238 to the restart; an open teardown is left alone for the
83
83
  // idempotent path).
84
- assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/);
84
+ assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/);
85
85
  updates++;
86
86
  // The supersede must skip the platform's own row IN ITS OWN SQL (task 1004286): the
87
87
  // guard refuses that teardown only afterwards, in enqueueIntent. Modelled, not assumed.
@@ -0,0 +1,153 @@
1
+ // tests/render_api.mjs — the Render client's workspace guard (task 1004183; ADR 0327).
2
+ //
3
+ // The load-bearing assertions:
4
+ // • a client cannot exist without the workspace it acts in — never defaulted
5
+ // • a response from ANOTHER workspace is refused (RenderOwnerMismatch), and so is a
6
+ // response carrying no workspace — in both of Render's shapes (service `ownerId`,
7
+ // Postgres `owner.id`) and on every element that carries one
8
+ // • the calls whose answer carries no owner (connection-info, env-var PUT, deploy) are
9
+ // only sent after the parent resource was read through the guard — so a service in
10
+ // another workspace gets NO write
11
+ // • there is no delete, and the verb is refused outright
12
+ // • a dry-run sends nothing and never prints an env var value
13
+ //
14
+ // Hermetic: a recording fake fetch. Run: node tests/render_api.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const { RenderApi, RenderApiError, RenderOwnerMismatch, ownerOf } = require('../scripts/gds/render-api.js');
21
+
22
+ let passed = 0, failed = 0;
23
+ async function ta(name, fn) {
24
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
25
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
26
+ }
27
+
28
+ const OURS = 'tea-ours00000001';
29
+ const THEIRS = 'tea-theirs000001';
30
+
31
+ // A fake Render: `routes` maps "METHOD path" to the JSON it answers.
32
+ function fakeFetch(routes) {
33
+ const calls = [];
34
+ const fetchImpl = async (url, init) => {
35
+ const path = url.replace('https://api.render.com/v1', '');
36
+ calls.push({ method: init.method, path, body: init.body ? JSON.parse(init.body) : null, auth: init.headers.Authorization });
37
+ const key = `${init.method} ${path}`;
38
+ if (!(key in routes)) return { ok: false, status: 404, text: async () => JSON.stringify({ message: 'not found' }) };
39
+ return { ok: true, status: 200, text: async () => JSON.stringify(routes[key]) };
40
+ };
41
+ return { fetchImpl, calls };
42
+ }
43
+ const live = (routes, ownerId = OURS) => {
44
+ const f = fakeFetch(routes);
45
+ return { api: new RenderApi({ token: 'rnd_test', ownerId, dryRun: false, fetchImpl: f.fetchImpl }), calls: f.calls };
46
+ };
47
+
48
+ console.log('the workspace is required:');
49
+ await ta('no ownerId → the client refuses to exist, even in dry-run', () => {
50
+ assert.throws(() => new RenderApi({ token: 'rnd_x', dryRun: false, fetchImpl: () => {} }), RenderApiError);
51
+ assert.throws(() => new RenderApi({}), /never defaulted/);
52
+ assert.throws(() => new RenderApi({ ownerId: ' ' }), RenderApiError);
53
+ });
54
+ await ta('live with no key → refuses', () => {
55
+ assert.throws(() => new RenderApi({ ownerId: OURS, dryRun: false, fetchImpl: () => {} }), /no API key/);
56
+ });
57
+
58
+ console.log('\nthe guard is on the RESPONSE:');
59
+ await ta('a service from another workspace is refused', async () => {
60
+ const { api } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: THEIRS } });
61
+ await assert.rejects(api.getService('srv-1'), RenderOwnerMismatch);
62
+ });
63
+ await ta('a service carrying no workspace is refused', async () => {
64
+ const { api } = live({ 'GET /services/srv-1': { id: 'srv-1' } });
65
+ await assert.rejects(api.getService('srv-1'), /no workspace/);
66
+ });
67
+ await ta('a service from our workspace passes, in either envelope', async () => {
68
+ const { api } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: OURS }, 'GET /services/srv-2': { service: { id: 'srv-2', ownerId: OURS } } });
69
+ assert.equal((await api.getService('srv-1')).id, 'srv-1');
70
+ assert.equal((await api.getService('srv-2')).id, 'srv-2');
71
+ });
72
+ await ta('a Postgres carries its owner as owner.id — a mismatch there is refused too', async () => {
73
+ const { api } = live({ 'GET /postgres/dpg-1': { id: 'dpg-1', owner: { id: THEIRS } } });
74
+ await assert.rejects(api.getPostgres('dpg-1'), RenderOwnerMismatch);
75
+ assert.equal(ownerOf({ owner: { id: OURS } }), OURS);
76
+ assert.equal(ownerOf({ ownerId: OURS }), OURS);
77
+ assert.equal(ownerOf({}), null);
78
+ });
79
+ await ta('a create answered from another workspace is refused', async () => {
80
+ const { api } = live({
81
+ 'POST /services': { deployId: 'dep-1', service: { id: 'srv-9', ownerId: THEIRS } },
82
+ 'POST /postgres': { id: 'dpg-9', owner: { id: THEIRS } },
83
+ });
84
+ await assert.rejects(api.createService({ ownerId: OURS, name: 'x' }), RenderOwnerMismatch);
85
+ await assert.rejects(api.createPostgres({ ownerId: OURS, name: 'x' }), RenderOwnerMismatch);
86
+ });
87
+ await ta('a payload naming another workspace is never sent', async () => {
88
+ const { api, calls } = live({});
89
+ await assert.rejects(api.createService({ ownerId: THEIRS, name: 'x' }), /this client's workspace/);
90
+ await assert.rejects(api.createPostgres({ name: 'x' }), /this client's workspace/);
91
+ assert.equal(calls.length, 0);
92
+ });
93
+
94
+ console.log('\nownerless answers are reached only through a checked parent:');
95
+ await ta('an env var for a service in another workspace is never PUT', async () => {
96
+ const { api, calls } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: THEIRS }, 'PUT /services/srv-1/env-vars/K': { key: 'K' } });
97
+ await assert.rejects(api.putEnvVar('srv-1', 'K', 'v'), RenderOwnerMismatch);
98
+ assert.deepEqual(calls.map((c) => c.method), ['GET'], 'the PUT is never sent');
99
+ });
100
+ await ta('a deploy for a service in another workspace is never triggered', async () => {
101
+ const { api, calls } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: THEIRS } });
102
+ await assert.rejects(api.triggerDeploy('srv-1'), RenderOwnerMismatch);
103
+ assert.deepEqual(calls.map((c) => c.method), ['GET']);
104
+ });
105
+ await ta('connection-info for a database in another workspace is never read', async () => {
106
+ const { api, calls } = live({ 'GET /postgres/dpg-1': { id: 'dpg-1', owner: { id: THEIRS } }, 'GET /postgres/dpg-1/connection-info': { internalConnectionString: 'postgres://leak' } });
107
+ await assert.rejects(api.postgresConnectionInfo('dpg-1'), RenderOwnerMismatch);
108
+ assert.equal(calls.length, 1);
109
+ });
110
+ await ta('on our own resources they go through, with the key as a bearer', async () => {
111
+ const { api, calls } = live({
112
+ 'GET /services/srv-1': { id: 'srv-1', ownerId: OURS },
113
+ 'PUT /services/srv-1/env-vars/K': { key: 'K' },
114
+ 'POST /services/srv-1/deploys': { id: 'dep-2' },
115
+ });
116
+ await api.putEnvVar('srv-1', 'K', 'v');
117
+ await api.triggerDeploy('srv-1');
118
+ assert.deepEqual(calls.map((c) => `${c.method} ${c.path}`), ['GET /services/srv-1', 'PUT /services/srv-1/env-vars/K', 'GET /services/srv-1', 'POST /services/srv-1/deploys']);
119
+ assert.deepEqual(calls[1].body, { value: 'v' });
120
+ assert.ok(calls.every((c) => c.auth === 'Bearer rnd_test'));
121
+ });
122
+
123
+ console.log('\nthe workspace list is for membership, never selection:');
124
+ await ta('listOwners returns every workspace the key reaches, unwrapped', async () => {
125
+ const { api } = live({ 'GET /owners?limit=100': [{ owner: { id: THEIRS, type: 'team' }, cursor: 'a' }, { owner: { id: OURS, type: 'team' }, cursor: 'b' }] });
126
+ assert.deepEqual((await api.listOwners()).map((o) => o.id), [THEIRS, OURS]);
127
+ });
128
+
129
+ console.log('\nno delete:');
130
+ await ta('there is no delete method, and the verb is refused', async () => {
131
+ const { api, calls } = live({});
132
+ const methods = Object.getOwnPropertyNames(RenderApi.prototype);
133
+ assert.ok(!methods.some((m) => /delete|destroy|remove|suspend/i.test(m)), `no destructive method: ${methods.join(', ')}`);
134
+ await assert.rejects(api._req('DELETE', '/services/srv-1'), /never deletes/);
135
+ assert.equal(calls.length, 0);
136
+ });
137
+
138
+ console.log('\ndry-run:');
139
+ await ta('dry-run is the default, sends nothing, and prints no env var value', async () => {
140
+ const lines = [];
141
+ let sent = 0;
142
+ const api = new RenderApi({ ownerId: OURS, fetchImpl: () => { sent++; }, log: (l) => lines.push(l) });
143
+ const r = await api.createService({ ownerId: OURS, name: 'demo', envVars: [{ key: 'DATABASE_URL', value: 'postgres://secret' }] });
144
+ assert.equal(sent, 0);
145
+ assert.equal(r.service.ownerId, OURS);
146
+ await api.putEnvVar('srv-1', 'BUILDER_SECRET_KEY', 'deadbeef');
147
+ const out = lines.join('\n');
148
+ assert.ok(!out.includes('postgres://secret') && !out.includes('deadbeef'), out);
149
+ assert.match(out, /DATABASE_URL/, 'the name is still shown');
150
+ });
151
+
152
+ console.log(`\n${passed} passed, ${failed} failed`);
153
+ if (failed) process.exit(1);
@@ -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', () => {