@bongos/core 1.25.19 → 1.25.21

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 (131) hide show
  1. package/.bongos-core.json +237 -112
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +26 -9
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +10 -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 +444 -37
  10. package/docs/api-reference.md +11 -5
  11. package/docs/copy-inventory.md +223 -221
  12. package/docs/copy-registry.json +262 -244
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-readings.json +348 -347
  15. package/docs/project-context.template.md +1 -1
  16. package/modules/front-door-guide/handoff.js +120 -0
  17. package/modules/front-door-guide/module.json +1 -1
  18. package/modules/front-door-guide/routes/front-door-guide-plan.js +24 -0
  19. package/modules/government/board.js +8 -7
  20. package/modules/government/charter-advice.js +10 -3
  21. package/modules/government/charter.js +88 -4
  22. package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
  23. package/modules/government/routes/government.js +12 -12
  24. package/modules/government/sole-member.js +75 -0
  25. package/modules/hall-ui/public/ai-studio.html +1 -1
  26. package/modules/hall-ui/public/atlas.html +1 -1
  27. package/modules/hall-ui/public/blockers.html +1 -1
  28. package/modules/hall-ui/public/board-room.html +1 -1
  29. package/modules/hall-ui/public/city.html +1 -1
  30. package/modules/hall-ui/public/collab.html +1 -1
  31. package/modules/hall-ui/public/commands.html +1 -1
  32. package/modules/hall-ui/public/copy-desk.html +1 -1
  33. package/modules/hall-ui/public/deploy.html +1 -1
  34. package/modules/hall-ui/public/genesis-home.js +35 -9
  35. package/modules/hall-ui/public/goal-map.html +1 -1
  36. package/modules/hall-ui/public/goals.html +1 -1
  37. package/modules/hall-ui/public/government.html +1 -1
  38. package/modules/hall-ui/public/home-city-draw.js +14 -1
  39. package/modules/hall-ui/public/idea.html +1 -1
  40. package/modules/hall-ui/public/ideas.html +1 -1
  41. package/modules/hall-ui/public/index.html +1 -1
  42. package/modules/hall-ui/public/market-watch-item.html +1 -1
  43. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  44. package/modules/hall-ui/public/market-watch.html +1 -1
  45. package/modules/hall-ui/public/modules.html +1 -1
  46. package/modules/hall-ui/public/money.html +1 -1
  47. package/modules/hall-ui/public/my-agents.html +1 -1
  48. package/modules/hall-ui/public/primer.html +1 -1
  49. package/modules/hall-ui/public/profile.html +1 -1
  50. package/modules/hall-ui/public/project-settings.html +1 -1
  51. package/modules/hall-ui/public/roster.html +1 -1
  52. package/modules/hall-ui/public/settings.html +1 -1
  53. package/modules/hall-ui/public/shell.js +32 -20
  54. package/modules/hall-ui/public/street.html +1 -1
  55. package/modules/hall-ui/public/studio.html +1 -1
  56. package/modules/hall-ui/public/style.css +9 -4
  57. package/modules/hall-ui/public/task.html +1 -1
  58. package/modules/hall-ui/public/thinking.html +1 -1
  59. package/modules/hall-ui/public/tweak-editor.html +1 -1
  60. package/modules/hall-ui/public/watch.html +1 -1
  61. package/modules/hall-ui/public/work.html +1 -1
  62. package/modules/lifecycle/home-city.js +17 -3
  63. package/modules/onboarding/founding-mode.js +45 -1
  64. package/modules/onboarding/genesis-home.js +16 -2
  65. package/modules/onboarding/guide-handoff.js +109 -0
  66. package/modules/onboarding/routes/genesis-work.js +9 -5
  67. package/modules/platform-identity/module.json +1 -1
  68. package/modules/platform-identity/project-standing.js +49 -0
  69. package/modules/platform-identity/routes/my-projects.js +8 -0
  70. package/modules/provisioning/grants.js +126 -0
  71. package/modules/provisioning/guide-handoff.js +65 -0
  72. package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
  73. package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
  74. package/modules/provisioning/module.json +2 -1
  75. package/modules/provisioning/project-refusal.js +107 -0
  76. package/modules/provisioning/provisioning.js +4 -4
  77. package/modules/provisioning/render-lookup.js +36 -1
  78. package/modules/provisioning/render-promote.js +80 -0
  79. package/modules/provisioning/route-helpers.js +70 -16
  80. package/modules/provisioning/routes/env-manifest.js +21 -5
  81. package/modules/provisioning/routes/grants.js +141 -0
  82. package/modules/provisioning/routes/provisioning.js +12 -9
  83. package/modules/provisioning/routes/render-standup.js +82 -3
  84. package/modules/public-landing/public/projects.html +178 -1
  85. package/package-lock.json +2 -2
  86. package/package.json +1 -1
  87. package/release-notes.json +28 -0
  88. package/scripts/bongos/genesis.js +11 -1
  89. package/scripts/bongos/government-walk-sole.js +41 -0
  90. package/scripts/bongos/government-walk.js +15 -16
  91. package/scripts/bongos/provision-render-common.js +48 -0
  92. package/scripts/bongos/provision-render-promote.js +93 -0
  93. package/scripts/bongos/provision-render-sandbox.js +14 -2
  94. package/scripts/bongos/provision-render.js +4 -36
  95. package/scripts/bongos/provision.js +1 -0
  96. package/scripts/bongos/render-api.js +17 -2
  97. package/scripts/gds/government-walk-sole.js +6 -0
  98. package/scripts/gds/provision-render-common.js +6 -0
  99. package/scripts/gds/provision-render-promote.js +6 -0
  100. package/src/bongos/founding-page-gate.js +67 -0
  101. package/src/bongos/serve-internal.js +3 -0
  102. package/src/branding.js +4 -0
  103. package/src/module-api.js +1 -1
  104. package/tests/fleet_cost_view.mjs +2 -0
  105. package/tests/founding_mode.mjs +84 -8
  106. package/tests/founding_page_gate.mjs +105 -0
  107. package/tests/front_door_guide_existing.mjs +1 -0
  108. package/tests/genesis_government.mjs +5 -1
  109. package/tests/genesis_home.mjs +41 -0
  110. package/tests/government_charter_advice.mjs +49 -0
  111. package/tests/government_charter_forms.mjs +14 -3
  112. package/tests/government_seed.mjs +1 -0
  113. package/tests/government_sole_member.mjs +153 -0
  114. package/tests/guide_handoff.mjs +322 -0
  115. package/tests/home_city.mjs +52 -0
  116. package/tests/nav_permission_atoms.mjs +1 -1
  117. package/tests/project_catalog_routes.mjs +2 -0
  118. package/tests/provision_settings_apply.mjs +2 -1
  119. package/tests/provisioning_env_manifest.mjs +4 -0
  120. package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
  121. package/tests/provisioning_project_grants.mjs +458 -0
  122. package/tests/provisioning_project_owner_gate.mjs +3 -1
  123. package/tests/provisioning_project_refusal.mjs +316 -0
  124. package/tests/provisioning_render_route.mjs +1 -1
  125. package/tests/provisioning_restart.mjs +2 -0
  126. package/tests/provisioning_settings.mjs +2 -0
  127. package/tests/provisioning_settings_apply.mjs +1 -1
  128. package/tests/provisioning_settings_env.mjs +2 -2
  129. package/tests/provisioning_teardown_intent.mjs +2 -2
  130. package/tests/render_promote.mjs +309 -0
  131. package/tests/render_sandbox.mjs +9 -4
@@ -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 };
@@ -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) {
@@ -120,6 +121,17 @@ async function standupSandbox(api, inst, host, deps) {
120
121
  }
121
122
  const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
122
123
  await recordSandbox(db, inst.id, { state: 'deploying', serviceId: svc.id, serviceUrl: url, error: null });
124
+ // From here on finished work reaches the sandbox only: the live service stops deploying
125
+ // on every merge, and moves when a person promotes (task 1005290, ADR 0406 §2). Only once
126
+ // the sandbox exists, so live is never left with nowhere for work to land. A failure is
127
+ // said on the row and the sandbox stands; the next promotion switches it again.
128
+ try {
129
+ await api.setAutoDeploy(host.serviceId, false);
130
+ await recordAppHost(db, inst.id, { liveAutoDeploy: 'off' }); // the one app_host writer
131
+ log(' [render] the live app now moves only when a person promotes');
132
+ } catch (e) {
133
+ log(` (the live app still deploys every merge: ${e && e.message})`);
134
+ }
123
135
  return { ok: true };
124
136
  } catch (e) {
125
137
  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
@@ -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');
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/founding-page-gate.js — A SLEEPING ROOM IS NOT REACHABLE BY URL (task 1005112).
4
+ //
5
+ // While a project is being founded, the hall's sidebar holds only the rooms its genesis
6
+ // stages have opened. Hiding a room and refusing its page are ONE change: a room nobody
7
+ // can see but anyone can type the address of would be a wall the product only claims to
8
+ // have. So this is the page half. It asks the server's own answer, the onboarding port's
9
+ // foundingModeFor() (the same read /me carries as founding_mode.asleep, keyed by the
10
+ // sidebar's room ids), and never a browser's opinion (ADR 0016).
11
+ //
12
+ // A sleeping room's page answers with a redirect home carrying ?asleep=<room>&opens=<stage>,
13
+ // where the shell says what the room is and which stage opens it, instead of a dead end.
14
+ //
15
+ // What it never touches: a project that is not founding (foundingModeFor answers null,
16
+ // and the page is served exactly as before), a room that is awake, the Primer and the
17
+ // Board Room (never asleep), and every page that is not one of the rooms below. An
18
+ // unreadable founding state is null too, so a failure opens the page, never shuts it:
19
+ // founding-mode.js fails closed to OFF for the same reason (a hall wrongly asleep would
20
+ // hijack it). Rooms are keyed by the sidebar id, and each room's page slug IS that id.
21
+
22
+ const path = require('path');
23
+ const seams = require('../module-seams');
24
+
25
+ // The rooms a genesis stage can put to sleep (founding-mode.js WAKES + WAKES_WHEN_ALIVE),
26
+ // by page slug. tests/founding_page_gate.mjs holds this list to that map.
27
+ const ROOM_PAGE_RE = /^\/builders\/(work|goals|goal-map|collab|roster)(?:\.html)?\/?$/;
28
+
29
+ // asleepRoomFor(room, founding) — PURE. The room id when that room sleeps under this
30
+ // founding answer, else null.
31
+ function asleepRoomFor(room, founding) {
32
+ if (!founding || founding.active !== true || !founding.asleep || typeof founding.asleep !== 'object') return null;
33
+ return Object.prototype.hasOwnProperty.call(founding.asleep, room) ? room : null;
34
+ }
35
+
36
+ // readFounding() — the server's answer, or null on any failure (fail open: OFF).
37
+ async function readFounding(resolve = seams.resolveOptional) {
38
+ try {
39
+ const onboarding = resolve('onboarding');
40
+ if (!onboarding || typeof onboarding.foundingModeFor !== 'function') return null;
41
+ return await onboarding.foundingModeFor();
42
+ } catch (_) {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ // foundingPageGate({ skip, isBuildersHost, read }) — the Express middleware. `skip(req)`
48
+ // is the caller's module-surface test; `read` is injectable for tests. The founding read
49
+ // is made only for one of the five room pages, so every other request pays nothing.
50
+ function foundingPageGate({ skip = () => false, isBuildersHost = () => false, read = readFounding } = {}) {
51
+ return async function gate(req, res, next) {
52
+ if (skip(req) || !String(req.path || '').startsWith('/builders')) return next();
53
+ const m = ROOM_PAGE_RE.exec(path.posix.normalize(req.path));
54
+ if (!m) return next();
55
+ let founding = null;
56
+ try { founding = await read(); } catch (_) { founding = null; } // fail open: OFF
57
+ const room = asleepRoomFor(m[1], founding);
58
+ if (!room) return next();
59
+ const opens = String(founding.asleep[room] || '');
60
+ res.set('Cache-Control', 'no-store');
61
+ return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/?asleep=${encodeURIComponent(room)}`
62
+ + (/^[a-z]{1,20}$/.test(opens) ? `&opens=${opens}` : ''));
63
+ };
64
+ }
65
+
66
+ // One export: the pure parts are proven through it (tests/founding_page_gate.mjs).
67
+ module.exports = { foundingPageGate };
@@ -50,6 +50,7 @@ const { linkPreviewTags } = require('../link-preview');
50
50
  const { prelaunchGate } = require('./prelaunch-gate');
51
51
  const { platformVisibilityGate } = require('./platform-visibility-gate');
52
52
  const { schemaPending } = require('./schema-lag');
53
+ const { foundingPageGate } = require('./founding-page-gate');
53
54
  const { clientModules, isModuleEnabled } = require('../modules');
54
55
  const { hallWidgetScripts, moduleWebSurfaces } = require('../module-loader/loader');
55
56
  const { resolveCoreRoot, resolveDocsRoot, resolveInstanceRoot, resolveEnv } = require('../instance-config');
@@ -1353,6 +1354,8 @@ function mountInternalSurfaces(app) {
1353
1354
  }
1354
1355
  next();
1355
1356
  });
1357
+ // task 1005112: a founding project's sleeping room is refused by URL, after sign-in.
1358
+ app.use(foundingPageGate({ skip: isModuleSurfaceHost, isBuildersHost }));
1356
1359
 
1357
1360
  // task 1965: the read-only Task Board (/board, task 751) was consolidated into
1358
1361
  // the Work page (/work) — the hall keeps one task-listing surface. 301 old
package/src/branding.js CHANGED
@@ -112,6 +112,10 @@ const ENV_OVERRIDES = [
112
112
  // The ship mode the founder picked in the wizard (task 1004629), empty when none was.
113
113
  // resolveShipMode reads it only while project_settings['ship.mode'] is unset.
114
114
  ['SHIP_MODE', ['founding', 'shipMode']],
115
+ // The start-a-project guide's hand-off (task 1005274, modules/provisioning/guide-handoff.js): base64url
116
+ // JSON { scope, markdown }, empty when the founder never used the guide. modules/onboarding/guide-handoff.js
117
+ // writes it once: the scope answers as carried over, and the hand-off file. SERVER-ONLY, like the birth.
118
+ ['GUIDE_HANDOFF', ['founding', 'guideHandoff']],
115
119
  ];
116
120
 
117
121
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.19'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.21'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -135,6 +135,8 @@ const provisioning = require(path.join(ROOT, 'modules', 'provisioning', 'provisi
135
135
  const api = require(path.join(ROOT, 'src', 'module-api.js'));
136
136
  let detailRow = null;
137
137
  provisioning.getInstanceById = async () => detailRow;
138
+ // The gate also admits an owner-made grant (task 1004161, ADR 0407); nobody in this file holds one.
139
+ require(path.join(ROOT, 'modules', 'provisioning', 'grants.js')).liveGrant = async () => null;
138
140
  provisioning.getOpenIntent = async () => null;
139
141
  provisioning.getLatestOAuthManifestForInstance = async () => null;
140
142
  require(path.join(ROOT, 'modules', 'provisioning', 'catalog-bridge.js')).healInstanceCatalog = async () => {};