@bongos/core 1.25.20 → 1.25.22

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 (99) hide show
  1. package/.bongos-core.json +213 -78
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +18 -0
  4. package/clients/bongos-client/index.cjs +18 -0
  5. package/clients/bongos-client/index.d.ts +34 -8
  6. package/clients/bongos-client/index.mjs +18 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +18 -0
  8. package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
  9. package/docs/api/openapi.json +693 -125
  10. package/docs/api-reference.md +13 -4
  11. package/docs/copy-inventory.md +432 -427
  12. package/docs/copy-registry.json +571 -526
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +350 -344
  16. package/modules/government/board.js +8 -7
  17. package/modules/government/charter-advice.js +10 -3
  18. package/modules/government/charter.js +88 -4
  19. package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
  20. package/modules/government/routes/government.js +12 -12
  21. package/modules/government/sole-member.js +75 -0
  22. package/modules/hall-ui/public/genesis-home.js +12 -5
  23. package/modules/lifecycle/kickoff-checklist.js +1 -1
  24. package/modules/lifecycle/routes/pay-type-gates.js +7 -2
  25. package/modules/onboarding/genesis-home.js +14 -2
  26. package/modules/onboarding/project-facts.js +318 -0
  27. package/modules/onboarding/routes/genesis-work.js +16 -0
  28. package/modules/platform-identity/module.json +1 -1
  29. package/modules/platform-identity/project-standing.js +49 -0
  30. package/modules/platform-identity/routes/my-projects.js +8 -0
  31. package/modules/provisioning/grants.js +126 -0
  32. package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
  33. package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
  34. package/modules/provisioning/module.json +2 -1
  35. package/modules/provisioning/project-facts.js +55 -0
  36. package/modules/provisioning/project-refusal.js +107 -0
  37. package/modules/provisioning/provisioning.js +6 -3
  38. package/modules/provisioning/render-lookup.js +36 -1
  39. package/modules/provisioning/render-promote.js +80 -0
  40. package/modules/provisioning/render-sandbox.js +6 -0
  41. package/modules/provisioning/route-helpers.js +70 -16
  42. package/modules/provisioning/routes/env-manifest.js +21 -5
  43. package/modules/provisioning/routes/grants.js +141 -0
  44. package/modules/provisioning/routes/provisioning.js +8 -7
  45. package/modules/provisioning/routes/render-standup.js +82 -3
  46. package/modules/public-landing/public/projects.html +290 -32
  47. package/package-lock.json +2 -2
  48. package/package.json +2 -1
  49. package/release-notes.json +32 -0
  50. package/scripts/bongos/genesis.js +49 -3
  51. package/scripts/bongos/government-walk-sole.js +41 -0
  52. package/scripts/bongos/government-walk.js +15 -16
  53. package/scripts/bongos/init.js +13 -0
  54. package/scripts/bongos/package-core.js +2 -0
  55. package/scripts/bongos/provision-render-common.js +48 -0
  56. package/scripts/bongos/provision-render-promote.js +93 -0
  57. package/scripts/bongos/provision-render-sandbox.js +19 -3
  58. package/scripts/bongos/provision-render.js +4 -36
  59. package/scripts/bongos/provision.js +1 -0
  60. package/scripts/bongos/render-api.js +17 -2
  61. package/scripts/bongos/render-payload.js +5 -2
  62. package/scripts/gds/government-walk-sole.js +6 -0
  63. package/scripts/gds/provision-render-common.js +6 -0
  64. package/scripts/gds/provision-render-promote.js +6 -0
  65. package/src/bongos/folder-upload.js +243 -0
  66. package/src/bongos/routes/github.js +109 -16
  67. package/src/bongos/routes.js +3 -0
  68. package/src/branding.js +4 -0
  69. package/src/module-api.js +1 -1
  70. package/src/sandbox/gate.js +164 -0
  71. package/src/sandbox/index.js +144 -0
  72. package/tests/fleet_cost_view.mjs +2 -0
  73. package/tests/folder_upload_project.mjs +396 -0
  74. package/tests/genesis_government.mjs +5 -1
  75. package/tests/genesis_home.mjs +17 -0
  76. package/tests/genesis_work_routes.mjs +25 -1
  77. package/tests/government_charter_advice.mjs +49 -0
  78. package/tests/government_charter_forms.mjs +14 -3
  79. package/tests/government_seed.mjs +1 -0
  80. package/tests/government_sole_member.mjs +153 -0
  81. package/tests/project_catalog_routes.mjs +2 -0
  82. package/tests/project_facts.mjs +158 -0
  83. package/tests/projects_hub.mjs +1 -1
  84. package/tests/provision_settings_apply.mjs +2 -1
  85. package/tests/provisioning_env_manifest.mjs +4 -0
  86. package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
  87. package/tests/provisioning_project_grants.mjs +458 -0
  88. package/tests/provisioning_project_owner_gate.mjs +3 -1
  89. package/tests/provisioning_project_refusal.mjs +316 -0
  90. package/tests/provisioning_render_route.mjs +1 -1
  91. package/tests/provisioning_restart.mjs +2 -0
  92. package/tests/provisioning_settings.mjs +2 -0
  93. package/tests/provisioning_settings_apply.mjs +1 -1
  94. package/tests/provisioning_settings_env.mjs +2 -2
  95. package/tests/provisioning_teardown_intent.mjs +2 -2
  96. package/tests/public_landing_projects.mjs +27 -5
  97. package/tests/render_promote.mjs +309 -0
  98. package/tests/render_sandbox.mjs +16 -8
  99. package/tests/sandbox_gate.mjs +182 -0
@@ -49,6 +49,7 @@
49
49
  const fs = require('fs');
50
50
  const os = require('os');
51
51
  const path = require('path');
52
+ const { decidedOf, adoptedByFiling, soleMemberRows } = require('./government-walk-sole'); // task 1005130: a board of one decides by filing
52
53
 
53
54
  const ROOT = path.resolve(__dirname, '..', '..');
54
55
  const DOC_PATH = path.join(ROOT, 'docs', 'governance', 'government-walk.md');
@@ -262,9 +263,9 @@ function makeStore(catalog) {
262
263
  }
263
264
  return item.opened_by ?? null;
264
265
  },
265
- async insertBoardVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null }) {
266
+ async insertBoardVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null, castVia = 'ballot' }) {
266
267
  if (s.votes.some((v) => v.item_id === String(itemId) && v.voter_id === String(voterId))) return null;
267
- const row = { id: next(), item_id: String(itemId), voter_id: String(voterId), direction, reason_md: reasonMd, section_key: sectionKey, cast_at: tick() };
268
+ const row = { id: next(), item_id: String(itemId), voter_id: String(voterId), direction, reason_md: reasonMd, section_key: sectionKey, cast_via: castVia, cast_at: tick() };
268
269
  s.votes.push(row);
269
270
  return copy(row);
270
271
  },
@@ -774,20 +775,17 @@ async function runWalk() {
774
775
  return { got: `${r.status}, stage ${stage}`, pass: r.status === 200 && stage === 'decide' };
775
776
  });
776
777
  let charterItem = null;
777
- await check(`Deciding with ${CHOSEN} saves the plan and files the pick as a charter amendment`, 'plan saved, charter sitting, one vote decides', async () => {
778
+ await check(`Deciding with ${CHOSEN} saves the plan and files the pick as a charter amendment`, 'plan saved, charter adopted', async () => {
778
779
  const r = await call(FOUNDER, 'POST', '/founding/plan', { government: CHOSEN });
779
780
  const c = (r.json && r.json.charter) || {};
780
781
  charterItem = c.item_id || null;
781
- return { got: `${r.status}, charter ${c.state}, one vote decides ${c.one_vote_decides}`, pass: r.status === 200 && c.state === 'sitting' && c.one_vote_decides === true };
782
+ return { got: `${r.status}, charter ${c.state}`, pass: r.status === 200 && c.state === 'adopted' && !!charterItem };
782
783
  });
783
784
  await check('Saving the plan moved the project to its first stage', 'stage scope', async () => {
784
785
  const g = await genesis.readGenesis();
785
786
  return { got: `stage ${g && g.stage}`, pass: !!g && g.stage === 'scope' };
786
787
  });
787
- await check('The founder\'s one yes ratifies the pick under the Monarchy it is born with', 'passed', async () => {
788
- const r = await vote(FOUNDER, charterItem);
789
- return { got: `${r.status} ${r.json && r.json.outcome}`, pass: r.status === 201 && r.json.outcome === 'passed' };
790
- });
788
+ await check('The founder\'s one yes ratifies the pick under the Monarchy it is born with: as its only member, choosing it was that yes', 'passed; the founder\'s yes, cast by filing', async () => adoptedByFiling({ store, stateOf, itemId: charterItem, voterId: FOUNDER }));
791
789
  await check('The constitution now reads as the government chosen at decide', `reads as ${CHOSEN}, no divergence`, async () => {
792
790
  const c = await constitution();
793
791
  const f = c && c.charter && c.charter.reads_as && c.charter.reads_as.form;
@@ -928,8 +926,8 @@ async function runWalk() {
928
926
  const r = await call(FOUNDER, 'POST', '/government/board/decision-rules', { rules });
929
927
  const itemId = r.json && r.json.item && r.json.item.id;
930
928
  if (r.status !== 201 || !itemId) return { ok: false, why: `${r.status} ${code(r)}` };
931
- const v = await vote(FOUNDER, itemId);
932
- return { ok: v.status === 201 && v.json.outcome === 'passed', why: `vote ${v.status} ${v.json && v.json.outcome}` };
929
+ const d = decidedOf(r); // the founder is the Monarchy's only member, so filing it is their one yes
930
+ return { ok: d.closed === true && d.outcome === 'passed', why: `filed ${d.outcome || 'undecided'}` };
933
931
  }
934
932
  const rowOf = async (area) => {
935
933
  const c = await constitution();
@@ -1094,12 +1092,13 @@ async function walkNewMachinery({ call, code, vote, voteAll, check, section, sta
1094
1092
  const s = await positionsPort().lawsForSession();
1095
1093
  return { got: `${book.in_force} in force; ${s.inject ? 'a laws block' : 'no laws block'}`, pass: book.in_force === 0 && s.inject === false };
1096
1094
  });
1097
- await check('A law is proposed: filed as a laws amendment, and proposing writes nothing', '201, sitting open; still 0 in force', async () => {
1098
- const r = await call(FOUNDER_ID, 'POST', '/government/board/laws', { enact: [SHORT_LAW] });
1095
+ await check('A law is proposed by a builder the board does not seat: filed as a laws amendment, and proposing writes nothing', '201, sitting open; still 0 in force', async () => {
1096
+ const r = await call(BEN, 'POST', '/government/board/laws', { enact: [SHORT_LAW] });
1099
1097
  lawItem = itemIdOf(r);
1100
1098
  const book = await lawsInForce();
1101
1099
  return { got: `${r.status}, sitting ${stateOf(lawItem)}; still ${book.in_force} in force`, pass: r.status === 201 && stateOf(lawItem) === 'open' && book.in_force === 0 };
1102
1100
  });
1101
+ await soleMemberRows({ check, call, vote, code, store, stateOf, itemIdOf, ids: { EVE, BEN, FOUNDER_ID }, SHORT_LAW });
1103
1102
  await check('Someone the board does not seat cannot pass it', '403 not_board_member', async () => {
1104
1103
  const r = await vote(BEN, lawItem);
1105
1104
  return { got: `${r.status} ${code(r)}`, pass: r.status === 403 && code(r) === 'not_board_member' };
@@ -1134,9 +1133,9 @@ async function walkNewMachinery({ call, code, vote, voteAll, check, section, sta
1134
1133
  const r = await call(BEN, 'PATCH', '/me/interaction', { verbosity: 'concise' });
1135
1134
  return { got: `${r.status}`, pass: r.status === 200 };
1136
1135
  });
1137
- await check('Repealed by a vote, the law leaves every session and unlocks the setting', 'passed; 0 in force; no laws block; 200', async () => {
1136
+ await check('Repealed by the founder, the board\'s only member, whose filing is their one yes: the law leaves every session and unlocks the setting', 'passed; 0 in force; no laws block; 200', async () => {
1138
1137
  const r = await call(FOUNDER_ID, 'POST', '/government/board/laws', { repeal: [lawId] });
1139
- const v = await vote(FOUNDER_ID, itemIdOf(r));
1138
+ const v = { json: { outcome: decidedOf(r).outcome } };
1140
1139
  const book = await lawsInForce();
1141
1140
  const s = await positionsPort().lawsForSession();
1142
1141
  const w = await call(BEN, 'PATCH', '/me/interaction', { verbosity: 'detailed' });
@@ -1152,7 +1151,7 @@ async function walkNewMachinery({ call, code, vote, voteAll, check, section, sta
1152
1151
  await check(`Strictness: ${from} to ${key} is one vote, and its locks bite`, `one sitting; passed; ${key} in force; exploratory wandering ${wander}`, async () => {
1153
1152
  const r = await call(FOUNDER_ID, 'POST', '/government/board/presets', { preset: key });
1154
1153
  const id = itemIdOf(r);
1155
- const v = await vote(FOUNDER_ID, id);
1154
+ const v = { json: { outcome: decidedOf(r).outcome } }; // the only member's filing is their one yes
1156
1155
  const p = await presetNow();
1157
1156
  const w = await call(BEN, 'PATCH', '/me/wandering', { wandering: 3 });
1158
1157
  const ws = `${w.status}${w.status === 200 ? '' : ` ${code(w)}`}`;
@@ -1162,7 +1161,7 @@ async function walkNewMachinery({ call, code, vote, voteAll, check, section, sta
1162
1161
  await check('Every preset law is repealed by one more vote, so the book ends empty', 'passed; 0 in force; no preset', async () => {
1163
1162
  const ids = ((await lawsInForce()).laws || []).map((l) => l.id);
1164
1163
  const r = await call(FOUNDER_ID, 'POST', '/government/board/laws', { repeal: ids });
1165
- const v = await vote(FOUNDER_ID, itemIdOf(r));
1164
+ const v = { json: { outcome: decidedOf(r).outcome } }; // the only member's filing is their one yes
1166
1165
  const book = await lawsInForce();
1167
1166
  const p = await presetNow();
1168
1167
  return { got: `${v.json && v.json.outcome}; ${book.in_force} in force; ${p.current ? `${p.current} in force` : 'no preset'}`, pass: book.in_force === 0 && p.current == null };
@@ -545,6 +545,19 @@ function buildReadme(branding = {}) {
545
545
  '',
546
546
  'Requires Node >= 22 and Postgres installed locally (macOS/Linux; on Windows use WSL). If anything looks off, `bongos doctor` checks your setup and prints the fix.',
547
547
  '',
548
+ // The project's sandbox (task 1005291, ADR 0406 §5): the one line that makes the app
549
+ // behave safely when it runs as its sandbox copy, and nothing at all on the live site.
550
+ "## Your app's sandbox",
551
+ '',
552
+ "If your app runs on Render, Bongos also runs a free sandbox copy of it that takes every finished change first. Put this at the top of a Node app so the sandbox only reads from payment, email and messaging providers, and shows a \"Sandbox\" band on every page:",
553
+ '',
554
+ '```js',
555
+ "const sandbox = require('@bongos/core/sandbox').install(); // does nothing on the live site",
556
+ 'app.use(sandbox.middleware); // Express-style apps: the band, and /__bongos/sandbox',
557
+ '```',
558
+ '',
559
+ 'Seed test data when `BONGOS_SANDBOX=1`, and give the sandbox test keys. If you lend a real key, lend a read-only one.',
560
+ '',
548
561
  '## Where to look next',
549
562
  '',
550
563
  '- `CLAUDE.md` — how this project is built (the operating manual agents read first).',
@@ -330,6 +330,8 @@ function buildPackageJson({ version, sourceDeps, sourceOverrides }) {
330
330
  './bongos/api-path-404': './src/bongos/api-path-404.js',
331
331
  './module-loader/loader': './src/module-loader/loader.js',
332
332
  './modules': './src/modules.js',
333
+ // A project's app loads this to behave as its sandbox (task 1005291, ADR 0406 §5).
334
+ './sandbox': './src/sandbox/index.js',
333
335
  './module-seams': './src/module-seams.js',
334
336
  },
335
337
  engines: { node: '>=22' },
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+ // provision-render-common.js — the pieces every Render runner leg shares: the client for a
3
+ // lent key, the one app_host writer, clearing the key, and an owner-readable reason
4
+ // (task 1005290; first written in provision-render.js by task 1004183).
5
+ //
6
+ // WHY ITS OWN FILE. The standup leg (provision-render.js), the sandbox leg
7
+ // (provision-render-sandbox.js) and the promote leg (provision-render-promote.js) all
8
+ // need these. Kept in the standup file, the sandbox leg reaching back for them made a
9
+ // require cycle (the standup file requires the sandbox file), which the fitness ratchet
10
+ // rightly refuses. Here, every leg depends on this file and on nothing of each other's.
11
+
12
+ const { RenderApi } = require('./render-api.js');
13
+
14
+ // A key waiting longer than this was lent for a run that never came. It is refused and
15
+ // cleared rather than spent late.
16
+ const KEY_TTL_MS = 30 * 60 * 1000;
17
+
18
+ function renderApiFor(deps, token, ownerId) {
19
+ if (deps.renderApiFactory) return deps.renderApiFactory({ token, ownerId });
20
+ return new RenderApi({ token, ownerId, dryRun: !deps.apply, fetchImpl: deps.fetchImpl, log: deps.log });
21
+ }
22
+
23
+ // Merge `patch` into the row's app_host. The runner is the only writer while an intent
24
+ // runs, and the web tier only writes it when enqueuing, which the open-intent index
25
+ // serialises — so a merge needs no read-modify-write.
26
+ async function recordAppHost(db, instId, patch) {
27
+ await db.query(
28
+ `UPDATE provisioning_instances SET app_host = COALESCE(app_host, '{}'::jsonb) || $2::jsonb WHERE id = $1`,
29
+ [instId, JSON.stringify({ ...patch, updatedAt: new Date().toISOString() })]);
30
+ }
31
+
32
+ async function clearBorrowedKey(db, intentId) {
33
+ await db.query(`UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = $1`, [intentId]);
34
+ }
35
+
36
+ // The owner reads this reason on the setup screen (app-status.js oneSentence). A refusal
37
+ // from Render's API used to arrive as its raw line — "Render POST /services failed: …" —
38
+ // so an answer from Render is re-said as a sentence with Render's own reason kept whole
39
+ // (task 1004464). Anything else is already the leg's own words.
40
+ function plainReason(e) {
41
+ const msg = e && e.message ? e.message : String(e);
42
+ const said = e && e.status != null && e.body && typeof e.body.message === 'string' ? e.body.message.trim() : '';
43
+ if (said) return `Render would not set up the app: ${said}`;
44
+ const m = /^Render (?:GET|POST|PATCH|PUT|DELETE) \S+ failed: ([\s\S]+)$/.exec(msg);
45
+ return m ? `Render would not set up the app: ${m[1].trim()}` : msg;
46
+ }
47
+
48
+ module.exports = { KEY_TTL_MS, renderApiFor, recordAppHost, clearBorrowedKey, plainReason };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+ // provision-render-promote.js — a person promotes what ran on the sandbox to the live app:
3
+ // the runner half of the `render-promote` intent (task 1005290; ADR 0406 §2, ADR 0327).
4
+ //
5
+ // WHY. Once a project has a sandbox, finished work lands on the sandbox only (it follows
6
+ // `main`), and the live Render service no longer deploys on every merge. The live app
7
+ // moves only here, when a person pressed promote and lent their Render key for that one
8
+ // action. That is the project law "Land only. Ask before going live" made physical: no
9
+ // ship, in Auto or Manual mode, reaches this file; only an owner's press does.
10
+ //
11
+ // WHAT IT DOES, in order, and why the order matters:
12
+ // 1. checks the chosen change RAN on the sandbox (it is one of the sandbox's own deploys
13
+ // that went live there), and that the live app still runs what the owner was shown;
14
+ // 2. RECORDS the promotion (provisioning_promotions) — before anything is sent, so
15
+ // nothing reaches the live app through Bongos without a row saying who and what;
16
+ // 3. switches the live service to manual deploys (idempotent), so the next merge cannot
17
+ // reach it by itself;
18
+ // 4. asks Render to deploy exactly that commit to the live service.
19
+ //
20
+ // Like the standup leg: the key is decrypted here, used for this run, and cleared in a
21
+ // `finally`; every failure is terminal (the owner presses again with the key); every call
22
+ // goes through scripts/bongos/render-api.js, which pins the workspace.
23
+
24
+ const COMMIT_RE = /^[0-9a-f]{7,40}$/;
25
+ // Only a deploy that served on the sandbox can be promoted: one still building, or one
26
+ // whose build failed, never ran there for anyone to look at.
27
+ const SERVED = ['live', 'deactivated'];
28
+
29
+ const commitOf = (d) => (d && d.commit && d.commit.id) || null;
30
+
31
+ async function promoteInstance(inst, deps, intent) {
32
+ const { db, log } = deps;
33
+ const render = require('./provision-render-common.js');
34
+ const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
35
+ const host = inst.app_host || {};
36
+ const ask = host.promote || {};
37
+ let promotionId = null;
38
+ try {
39
+ if (!intent || !intent.borrowed_key_enc) return await refuse('no Render key came with the promotion — press promote again');
40
+ const age = Date.now() - new Date(intent.created_at || 0).getTime();
41
+ if (!(age < render.KEY_TTL_MS)) return await refuse('the Render key waited too long to be used and was cleared — press promote again');
42
+ if (host.platform !== 'render' || !host.serviceId || !host.ownerId) return await refuse('this project has no app on Render to promote to');
43
+ const sb = host.sandbox || {};
44
+ if (!sb.serviceId) return await refuse('this project has no sandbox to promote from');
45
+ if (!COMMIT_RE.test(String(ask.commitId || ''))) return await refuse('no change was chosen to promote');
46
+
47
+ const api = render.renderApiFor(deps, secretBox.decrypt(intent.borrowed_key_enc), host.ownerId);
48
+ const owners = await api.listOwners();
49
+ if (!owners.some((o) => o.id === host.ownerId)) return await refuse(`this Render key cannot reach workspace ${host.ownerId}`);
50
+
51
+ // 1. it ran on the sandbox, and live is what the owner was shown
52
+ const sandboxDeploys = await api.listDeploys(sb.serviceId, { limit: 50 });
53
+ const ran = sandboxDeploys.find((d) => commitOf(d) === ask.commitId && SERVED.includes(d.status));
54
+ if (!ran) return await refuse('that change has not run on the sandbox, so it cannot be promoted');
55
+ const liveDeploys = await api.listDeploys(host.serviceId, { limit: 10 });
56
+ const liveCommit = commitOf(liveDeploys.find((d) => d.status === 'live'));
57
+ if ((ask.expectLiveCommit || null) !== liveCommit) return await refuse('what your live app runs changed since you looked, so nothing was done — look again and promote');
58
+ if (liveCommit === ask.commitId) return await refuse('that change is already on your live app');
59
+
60
+ // 2. the record comes first
61
+ const message = String((ran.commit && ran.commit.message) || '').split('\n')[0].slice(0, 300);
62
+ const { rows } = await db.query(
63
+ `INSERT INTO provisioning_promotions (instance_id, commit_id, commit_message, from_commit_id, sandbox_deploy_id, requested_by, state)
64
+ VALUES ($1, $2, $3, $4, $5, $6, 'started') RETURNING id`,
65
+ [inst.id, ask.commitId, message, liveCommit, ran.id || null, ask.requestedBy == null ? null : String(ask.requestedBy)]);
66
+ promotionId = rows && rows[0] ? rows[0].id : null;
67
+
68
+ // 3. live stops following main, so only promotions move it
69
+ await api.setAutoDeploy(host.serviceId, false);
70
+ // 4. exactly that commit
71
+ const dep = await api.triggerDeploy(host.serviceId, { commitId: ask.commitId });
72
+ const deployId = (dep && dep.id) || null;
73
+ await db.query(`UPDATE provisioning_promotions SET state = 'sent', render_deploy_id = $2, updated_at = now() WHERE id = $1`, [promotionId, deployId]);
74
+ await render.recordAppHost(db, inst.id, { liveAutoDeploy: 'off', promote: { ...ask, state: 'sent', error: null, renderDeployId: deployId } });
75
+ log(` [render] ${inst.slug}: promoted ${ask.commitId.slice(0, 7)} from the sandbox to the live app (deploy ${deployId || 'started'})`);
76
+ return { ok: true };
77
+ } catch (e) {
78
+ return await refuse(render.plainReason(e).replace(/^Render would not set up the app/, 'Render would not promote it'));
79
+ } finally {
80
+ if (intent && intent.id != null) await render.clearBorrowedKey(db, intent.id);
81
+ }
82
+
83
+ async function refuse(error) {
84
+ if (promotionId != null) {
85
+ await db.query(`UPDATE provisioning_promotions SET state = 'failed', error = $2, updated_at = now() WHERE id = $1`, [promotionId, error]).catch(() => {});
86
+ }
87
+ await render.recordAppHost(db, inst.id, { promote: { ...ask, state: 'failed', error } }).catch(() => {});
88
+ log(` ✖ [render] ${inst.slug} promote: ${error}`);
89
+ return { ok: false, terminal: true, error };
90
+ }
91
+ }
92
+
93
+ module.exports = { promoteInstance, COMMIT_RE };
@@ -20,14 +20,15 @@
20
20
  // 3. SERVICE — a free web service named `<slug>-sandbox`, auto-deploying from `main`,
21
21
  // with BONGOS_SANDBOX=1. Found by name first, so a re-run never makes a second one.
22
22
  //
23
- // The live service is not touched here: switching it to manual deploys belongs with the
24
- // promote button that replaces them (task 1005290).
23
+ // Once the sandbox stands, the LIVE service is switched to manual deploys, so only a
24
+ // person's promotion moves it (task 1005290; scripts/bongos/provision-render-promote.js).
25
25
  //
26
26
  // TEARDOWN removes the sandbox service and the sandbox database only — never the live app
27
27
  // or its data (ADR 0406 §7). It needs the owner's key; without one the sandbox is
28
28
  // forgotten and the owner is told exactly what to delete.
29
29
 
30
30
  const payload = require('./render-payload.js');
31
+ const { recordAppHost } = require('./provision-render-common.js');
31
32
  const { parseTargetRef } = require('./provision-repo-auth.js');
32
33
 
33
34
  async function recordSandbox(db, instId, patch) {
@@ -111,15 +112,30 @@ async function standupSandbox(api, inst, host, deps) {
111
112
  let svc = sb.serviceId ? await api.getService(sb.serviceId).catch(() => null) : null;
112
113
  if (!svc) svc = await api.findServiceByName(name);
113
114
  if (!svc) {
115
+ // The sandbox page's key (task 1005291): kept on the row, which only the owner's
116
+ // project page reads, and set on the sandbox, which checks it.
117
+ const pageToken = require('node:crypto').randomBytes(24).toString('hex');
114
118
  const created = await api.createService(payload.sandboxServicePayload(inst, {
115
119
  ownerId: api.ownerId, repoUrl: `https://github.com/${repo.owner}/${repo.name}`, region, build,
116
- dbUrl: dbStage.state === 'ready' ? dbStage.url : null,
120
+ dbUrl: dbStage.state === 'ready' ? dbStage.url : null, pageToken,
117
121
  }));
122
+ await recordSandbox(db, inst.id, { pageKey: pageToken });
118
123
  svc = created.service;
119
124
  log(` [render] created sandbox service ${svc.id} (free, follows main)`);
120
125
  }
121
126
  const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
122
127
  await recordSandbox(db, inst.id, { state: 'deploying', serviceId: svc.id, serviceUrl: url, error: null });
128
+ // From here on finished work reaches the sandbox only: the live service stops deploying
129
+ // on every merge, and moves when a person promotes (task 1005290, ADR 0406 §2). Only once
130
+ // the sandbox exists, so live is never left with nowhere for work to land. A failure is
131
+ // said on the row and the sandbox stands; the next promotion switches it again.
132
+ try {
133
+ await api.setAutoDeploy(host.serviceId, false);
134
+ await recordAppHost(db, inst.id, { liveAutoDeploy: 'off' }); // the one app_host writer
135
+ log(' [render] the live app now moves only when a person promotes');
136
+ } catch (e) {
137
+ log(` (the live app still deploys every merge: ${e && e.message})`);
138
+ }
123
139
  return { ok: true };
124
140
  } catch (e) {
125
141
  const error = `The sandbox could not be set up: ${String((e && e.message) || e).split('\n')[0].slice(0, 240)}`;
@@ -31,32 +31,12 @@
31
31
  // Every Render call goes through scripts/bongos/render-api.js, which pins the workspace and
32
32
  // asserts it on every response, and which cannot delete.
33
33
 
34
- const { RenderApi } = require('./render-api.js');
35
34
  const payload = require('./render-payload.js');
36
35
  const { parseTargetRef } = require('./provision-repo-auth.js');
37
36
  const sandbox = require('./provision-render-sandbox.js');
38
-
39
- // A key waiting longer than this was lent for a run that never came. It is refused and
40
- // cleared rather than spent late.
41
- const KEY_TTL_MS = 30 * 60 * 1000;
42
-
43
- function renderApiFor(deps, token, ownerId) {
44
- if (deps.renderApiFactory) return deps.renderApiFactory({ token, ownerId });
45
- return new RenderApi({ token, ownerId, dryRun: !deps.apply, fetchImpl: deps.fetchImpl, log: deps.log });
46
- }
47
-
48
- // Merge `patch` into the row's app_host. The runner is the only writer while an intent
49
- // runs, and the web tier only writes it when enqueuing, which the open-intent index
50
- // serialises — so a merge needs no read-modify-write.
51
- async function recordAppHost(db, instId, patch) {
52
- await db.query(
53
- `UPDATE provisioning_instances SET app_host = COALESCE(app_host, '{}'::jsonb) || $2::jsonb WHERE id = $1`,
54
- [instId, JSON.stringify({ ...patch, updatedAt: new Date().toISOString() })]);
55
- }
56
-
57
- async function clearBorrowedKey(db, intentId) {
58
- await db.query(`UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = $1`, [intentId]);
59
- }
37
+ // The pieces every Render leg shares live in their own file, so the sandbox and promote
38
+ // legs can use them without a require cycle back into this one (task 1005290).
39
+ const { KEY_TTL_MS, renderApiFor, recordAppHost, clearBorrowedKey, plainReason } = require('./provision-render-common.js');
60
40
 
61
41
  // Whether a Render service's repository is this project's. Render reports it as a URL;
62
42
  // compared as owner/name, case-insensitively, the way GitHub resolves them.
@@ -118,18 +98,6 @@ async function greenfield(api, inst, host, deps) {
118
98
  return { ok: true };
119
99
  }
120
100
 
121
- // The owner reads this reason on the setup screen (app-status.js oneSentence). A refusal
122
- // from Render's API used to arrive as its raw line — "Render POST /services failed: …" —
123
- // so an answer from Render is re-said as a sentence with Render's own reason kept whole
124
- // (task 1004464). Anything else is already the leg's own words.
125
- function plainReason(e) {
126
- const msg = e && e.message ? e.message : String(e);
127
- const said = e && e.status != null && e.body && typeof e.body.message === 'string' ? e.body.message.trim() : '';
128
- if (said) return `Render would not set up the app: ${said}`;
129
- const m = /^Render (?:GET|POST|PATCH|PUT|DELETE) \S+ failed: ([\s\S]+)$/.exec(msg);
130
- return m ? `Render would not set up the app: ${m[1].trim()}` : msg;
131
- }
132
-
133
101
  async function renderStandupInstance(inst, deps, intent) {
134
102
  const { db, log } = deps;
135
103
  const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
@@ -233,4 +201,4 @@ async function forgetRenderApp(inst, deps) {
233
201
  if (apply) await recordAppHost(db, inst.id, { state: 'disconnected' });
234
202
  }
235
203
 
236
- module.exports = { renderStandupInstance, afterProvision, forgetRenderApp, clearBorrowedKey, recordAppHost, KEY_TTL_MS };
204
+ module.exports = { renderStandupInstance, afterProvision, forgetRenderApp, clearBorrowedKey, recordAppHost, renderApiFor, plainReason, KEY_TTL_MS };
@@ -1255,6 +1255,7 @@ async function cmdRunIntents(deps) {
1255
1255
  else if (intent.action === 'core-upgrade') result = await coreUpgradeInstance(inst, deps, intent); // also reads the INTENT: the version the owner previewed (ADR 0293 D4)
1256
1256
  else if (intent.action === 'core-upgrade-preflight') result = await preflightCoreUpgrade(inst, deps, intent); // changes nothing; caches what the move WOULD say (task 1004121)
1257
1257
  else if (intent.action === 'repo-private' || intent.action === 'render-standup') result = intent.action === 'repo-private' ? await require('./provision-repo-private.js').repoPrivateInstance(inst, deps) : await require('./provision-render.js').renderStandupInstance(inst, deps, intent); // wire the deploy key, THEN flip (task 1004193) · spend the borrowed Render key once (task 1004183); required here — this file is at the size ratchet
1258
+ else if (intent.action === 'render-promote') result = await require('./provision-render-promote.js').promoteInstance(inst, deps, intent); // a person's promotion of a sandbox change to the live app (task 1005290, ADR 0406)
1258
1259
  else result = { ok: false, error: `unknown_action '${intent.action}' — runner core ${coreVersionSafe() || '?'} does not know it (runner too old?); refusing to guess` };
1259
1260
  if (result && result.ok === false) {
1260
1261
  // WAIT OR RETIRE (task 1004447, intent-retry.js): a transient failure waits ~15m/1h/6h; a
@@ -163,9 +163,24 @@ class RenderApi extends JsonHttpApi {
163
163
  return super._req('DELETE', path, null);
164
164
  }
165
165
 
166
- async triggerDeploy(serviceId) {
166
+ // Start a deploy. With `commitId` Render builds THAT commit rather than the branch's
167
+ // newest — how a promotion moves the live app to exactly what ran on the sandbox (ADR
168
+ // 0406 §2, task 1005290). The answer carries no owner, so the service is read first.
169
+ async triggerDeploy(serviceId, { commitId } = {}) {
167
170
  await this.getService(serviceId);
168
- return this._req('POST', `/services/${encodeURIComponent(serviceId)}/deploys`, {}, { dryRunResult: { id: null } });
171
+ const body = commitId ? { commitId } : {};
172
+ return this._req('POST', `/services/${encodeURIComponent(serviceId)}/deploys`, body, { dryRunResult: { id: null } });
173
+ }
174
+
175
+ // Whether the service deploys on every push to its branch. The live app is switched to
176
+ // `no` once its project has a sandbox, so only a person's promotion moves it (ADR 0406
177
+ // §2, task 1005290). A write to the owner's service, through the guard, nothing else.
178
+ async setAutoDeploy(serviceId, on) {
179
+ await this.getService(serviceId);
180
+ const json = await this._req('PATCH', `/services/${encodeURIComponent(serviceId)}`, { autoDeploy: on ? 'yes' : 'no' }, {
181
+ dryRunResult: { id: serviceId, ownerId: this.ownerId, autoDeploy: on ? 'yes' : 'no' },
182
+ });
183
+ return this.assertOwned(json && json.service ? json.service : json, `service ${serviceId}`);
169
184
  }
170
185
 
171
186
  // A service's deploys, newest first, as the bare deploy objects (Render wraps each in
@@ -87,11 +87,14 @@ function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region,
87
87
  // with BONGOS_SANDBOX=1 so the app (and the starter's outbound gate, task 1005291) knows
88
88
  // it is not the live site. `dbUrl` is the sandbox's OWN database, never the live one.
89
89
  const SANDBOX_SUFFIX = 'sandbox';
90
- function sandboxServicePayload(inst, { ownerId, repoUrl, region, build, dbUrl }) {
90
+ // `pageToken` is the sandbox's own page key: its /__bongos/sandbox shows what was refused
91
+ // and which real keys are lent only to a visitor who carries it (the owner, via their
92
+ // owner-only project page). Not a credential to anything else (task 1005291).
93
+ function sandboxServicePayload(inst, { ownerId, repoUrl, region, build, dbUrl, pageToken }) {
91
94
  const p = renderServicePayload(inst, { ownerId, repoUrl, branch: 'main', webPlan: 'free', region, build, dbUrl });
92
95
  p.name = resourceName(inst, SANDBOX_SUFFIX);
93
96
  p.autoDeploy = 'yes';
94
- p.envVars = [{ key: 'BONGOS_SANDBOX', value: '1' }, ...p.envVars];
97
+ p.envVars = [{ key: 'BONGOS_SANDBOX', value: '1' }, ...(pageToken ? [{ key: 'BONGOS_SANDBOX_PAGE_KEY', value: pageToken }] : []), ...p.envVars];
95
98
  return p;
96
99
  }
97
100
 
@@ -0,0 +1,6 @@
1
+ // scripts/gds/government-walk-sole.js — @bongos-forwarder (task 1004848). Generated by
2
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
3
+ // The real script is scripts/bongos/government-walk-sole.js. This forwarder keeps callers that still name
4
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
5
+ 'use strict';
6
+ require('../bongos/forwarder.js').forward(module, 'government-walk-sole.js');
@@ -0,0 +1,6 @@
1
+ // scripts/gds/provision-render-common.js — @bongos-forwarder (task 1004848). Generated by
2
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
3
+ // The real script is scripts/bongos/provision-render-common.js. This forwarder keeps callers that still name
4
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
5
+ 'use strict';
6
+ require('../bongos/forwarder.js').forward(module, 'provision-render-common.js');
@@ -0,0 +1,6 @@
1
+ // scripts/gds/provision-render-promote.js — @bongos-forwarder (task 1004848). Generated by
2
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
3
+ // The real script is scripts/bongos/provision-render-promote.js. This forwarder keeps callers that still name
4
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
5
+ 'use strict';
6
+ require('../bongos/forwarder.js').forward(module, 'provision-render-promote.js');