@bongos/core 1.25.20 → 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 (75) hide show
  1. package/.bongos-core.json +157 -62
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +23 -8
  6. package/clients/bongos-client/index.mjs +10 -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 +409 -36
  10. package/docs/api-reference.md +7 -2
  11. package/docs/copy-inventory.md +20 -20
  12. package/docs/copy-registry.json +23 -23
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-readings.json +2 -2
  15. package/modules/government/board.js +8 -7
  16. package/modules/government/charter-advice.js +10 -3
  17. package/modules/government/charter.js +88 -4
  18. package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
  19. package/modules/government/routes/government.js +12 -12
  20. package/modules/government/sole-member.js +75 -0
  21. package/modules/hall-ui/public/genesis-home.js +12 -5
  22. package/modules/onboarding/genesis-home.js +14 -2
  23. package/modules/platform-identity/module.json +1 -1
  24. package/modules/platform-identity/project-standing.js +49 -0
  25. package/modules/platform-identity/routes/my-projects.js +8 -0
  26. package/modules/provisioning/grants.js +126 -0
  27. package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
  28. package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
  29. package/modules/provisioning/module.json +2 -1
  30. package/modules/provisioning/project-refusal.js +107 -0
  31. package/modules/provisioning/provisioning.js +2 -2
  32. package/modules/provisioning/render-lookup.js +36 -1
  33. package/modules/provisioning/render-promote.js +80 -0
  34. package/modules/provisioning/route-helpers.js +70 -16
  35. package/modules/provisioning/routes/env-manifest.js +21 -5
  36. package/modules/provisioning/routes/grants.js +141 -0
  37. package/modules/provisioning/routes/provisioning.js +8 -7
  38. package/modules/provisioning/routes/render-standup.js +82 -3
  39. package/modules/public-landing/public/projects.html +99 -1
  40. package/package-lock.json +2 -2
  41. package/package.json +1 -1
  42. package/release-notes.json +18 -0
  43. package/scripts/bongos/genesis.js +11 -1
  44. package/scripts/bongos/government-walk-sole.js +41 -0
  45. package/scripts/bongos/government-walk.js +15 -16
  46. package/scripts/bongos/provision-render-common.js +48 -0
  47. package/scripts/bongos/provision-render-promote.js +93 -0
  48. package/scripts/bongos/provision-render-sandbox.js +14 -2
  49. package/scripts/bongos/provision-render.js +4 -36
  50. package/scripts/bongos/provision.js +1 -0
  51. package/scripts/bongos/render-api.js +17 -2
  52. package/scripts/gds/government-walk-sole.js +6 -0
  53. package/scripts/gds/provision-render-common.js +6 -0
  54. package/scripts/gds/provision-render-promote.js +6 -0
  55. package/src/module-api.js +1 -1
  56. package/tests/fleet_cost_view.mjs +2 -0
  57. package/tests/genesis_government.mjs +5 -1
  58. package/tests/genesis_home.mjs +17 -0
  59. package/tests/government_charter_advice.mjs +49 -0
  60. package/tests/government_charter_forms.mjs +14 -3
  61. package/tests/government_seed.mjs +1 -0
  62. package/tests/government_sole_member.mjs +153 -0
  63. package/tests/project_catalog_routes.mjs +2 -0
  64. package/tests/provisioning_env_manifest.mjs +4 -0
  65. package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
  66. package/tests/provisioning_project_grants.mjs +458 -0
  67. package/tests/provisioning_project_owner_gate.mjs +3 -1
  68. package/tests/provisioning_project_refusal.mjs +316 -0
  69. package/tests/provisioning_render_route.mjs +1 -1
  70. package/tests/provisioning_restart.mjs +2 -0
  71. package/tests/provisioning_settings.mjs +2 -0
  72. package/tests/provisioning_settings_apply.mjs +1 -1
  73. package/tests/provisioning_teardown_intent.mjs +2 -2
  74. package/tests/render_promote.mjs +309 -0
  75. package/tests/render_sandbox.mjs +9 -4
@@ -68,7 +68,8 @@ const { callbackPage } = require('./callback-page'); const githubSetup = require
68
68
  const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth'); const testProject = require('../test-project'); const guideHandoff = require('../guide-handoff');
69
69
  const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
70
70
  const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
71
- const { requireProjectOwner, projectActor, isProjectOwner } = require('../route-helpers'); // the owner-or-archon gate (task 1004159, ADR 0328)
71
+ const { requireProjectOwner, projectActor, projectActorLabel, isProjectOwner, grantedView } = require('../route-helpers');
72
+ const GRANTEE = { grantee: 'operate' }; // the three routes here a granted helper may use (task 1004161, ADR 0407 §3) // the owner-or-archon gate (task 1004159, ADR 0328)
72
73
  // the kernel ports this module provides (moved out at the size ratchet, task 1003578)
73
74
  const { registerProvisioningSeams } = require('../seams');
74
75
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -658,7 +659,7 @@ module.exports = function provisioningRoutes() {
658
659
  // GET /provisioning/instances/:id — read one instance (the manage page's read).
659
660
  // rank: any authenticated builder (own resource; archon may read any — task
660
661
  // 1003041). 404 otherwise — does not leak another owner's instance existence.
661
- router.get('/provisioning/instances/:id', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
662
+ router.get('/provisioning/instances/:id', auth.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
662
663
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
663
664
  if (!id) return;
664
665
  try {
@@ -676,7 +677,7 @@ module.exports = function provisioningRoutes() {
676
677
  // panel recovers it from server state after a reload — not from sessionStorage.
677
678
  const manifest = await provisioning.getLatestOAuthManifestForInstance(pool, id);
678
679
  const githubSetupNow = await githubSetup.githubSetupRead(pool, inst, manifest, manifest ? provisioning.publicGithubApp(manifest).status : null, { withState: false });
679
- res.json({
680
+ res.json(grantedView(req, {
680
681
  // the standing install nudge rides the instance, as on the list read (task 1004763)
681
682
  instance: { ...ownerInstance(inst), github_install: githubSetup.installNudge(githubSetupNow) },
682
683
  open_intent: open ? { action: open.action, state: open.state } : null,
@@ -684,7 +685,7 @@ module.exports = function provisioningRoutes() {
684
685
  // the four-step checklist from what the runner verified (task 1004649) — the
685
686
  // install link rides WITHOUT the state token this read never carries
686
687
  github_setup: githubSetupNow,
687
- });
688
+ }));
688
689
  } catch (err) {
689
690
  log.error('[provisioning] GET /provisioning/instances/:id', err);
690
691
  failFrom(res, err, 'read_failed');
@@ -874,7 +875,7 @@ module.exports = function provisioningRoutes() {
874
875
  // GET /provisioning/instances/:id/settings — read a project's effective
875
876
  // settings + the allowed values (so a form can render its choices).
876
877
  // rank: any authenticated builder (own resource; archon may read any).
877
- router.get('/provisioning/instances/:id/settings', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
878
+ router.get('/provisioning/instances/:id/settings', auth.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
878
879
  try {
879
880
  const inst = req.project;
880
881
  // `planet` rides beside `settings` (task 1003347): the same row, read by a
@@ -1038,7 +1039,7 @@ module.exports = function provisioningRoutes() {
1038
1039
  // Every refusal names the action that IS available in that state rather than
1039
1040
  // hiding behind a code, and no branch promises a duration — a restart's downtime
1040
1041
  // is exactly what this route cannot know (the F16 rule, ADR 0190 §7).
1041
- router.post('/provisioning/instances/:id/restart', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
1042
+ router.post('/provisioning/instances/:id/restart', auth.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
1042
1043
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
1043
1044
  if (!id) return;
1044
1045
  try {
@@ -1069,7 +1070,7 @@ module.exports = function provisioningRoutes() {
1069
1070
  if (r.created) {
1070
1071
  await provisioning.recordEvent(pool, {
1071
1072
  instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'restart',
1072
- detail: 'restart requested by owner', actor,
1073
+ detail: `restart requested by ${projectActorLabel(req)}`, actor,
1073
1074
  }).catch(() => {});
1074
1075
  }
1075
1076
  res.json({ ok: true, queued: r.created, action: 'restart', status: inst.status, message: r.created
@@ -23,7 +23,8 @@ const { pool, validateOrRespond, parseId } = api;
23
23
  const provisioning = require('../provisioning');
24
24
  const renderStandup = require('../render-standup');
25
25
  const { failFrom } = require('../public-refusal');
26
- const { lookupRender, RenderLookupError } = require('../render-lookup');
26
+ const { lookupRender, lookupPromotion, RenderLookupError } = require('../render-lookup');
27
+ const renderPromote = require('../render-promote');
27
28
  const { appStatus } = require('../app-status');
28
29
  const renderSandbox = require('../render-sandbox');
29
30
  const envManifest = require('../env-manifest');
@@ -46,6 +47,10 @@ const LOOKUP_STATUS = { render_key_refused: 422, render_workspace_unreachable: 4
46
47
  // OWNER ONLY, no archon override, for every Render route: the key and the account it acts
47
48
  // in are the owner's. A missing project and someone else's answer the same 404, so an id is
48
49
  // never confirmed.
50
+ // One sentence for "another run holds this project, so the key was dropped" — the sandbox
51
+ // and promote routes say it the same way (voice.md V1: one fixed sentence per concept).
52
+ const BUSY_KEY_NOT_KEPT = 'Something else is being done to this project right now, so your key was not kept. Try again in a minute.';
53
+
49
54
  function ownedBy(inst, builder) { return !!(inst && builder && String(inst.owner_builder_id) === String(builder.id)); }
50
55
 
51
56
  module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
@@ -105,7 +110,9 @@ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
105
110
  : [];
106
111
  // The sandbox beside it (ADR 0406, task 1005289), in the same shape.
107
112
  const sandbox = renderSandbox.sandboxStatus(inst);
108
- res.json({ ok: true, app, sandbox, env_vars: envVars, values_held: false });
113
+ // Promoting from it (ADR 0406 §2, task 1005290): whether the owner can, and the record.
114
+ const promote = { ...renderPromote.promoteView(inst), recent: await renderPromote.listPromotions(pool, id) };
115
+ res.json({ ok: true, app, sandbox, promote, env_vars: envVars, values_held: false });
109
116
  } catch (err) {
110
117
  log.error('[provisioning] GET /provisioning/instances/:id/render', err && err.message);
111
118
  failFrom(res, err, 'render_status_failed');
@@ -139,7 +146,7 @@ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
139
146
  const r = await renderSandbox.enqueueSandboxAdd(pool, id, { keyEnc: api.secretBox.encrypt(req.body.key), requestedBy: 'api:self' });
140
147
  if (r.conflict) {
141
148
  return res.fail('intent_conflict', { status: 409,
142
- message: `A '${r.intent.action}' run is ${r.intent.state} for this project, so your key was not kept. Try again once it has finished.`,
149
+ message: BUSY_KEY_NOT_KEPT,
143
150
  details: { action: r.intent.action, state: r.intent.state } });
144
151
  }
145
152
  await provisioning.recordEvent(pool, {
@@ -155,6 +162,78 @@ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
155
162
  }
156
163
  });
157
164
 
165
+ // POST /provisioning/instances/:id/render/promote/look — rank: any authenticated builder,
166
+ // and OWNER ONLY. What is waiting on the sandbox to go live (ADR 0406 §2, task 1005290):
167
+ // the change the live app runs, and every change that has run on the sandbox since, newest
168
+ // first. A read-only look at Render on the owner's key held for this one request (ADR 0348
169
+ // as widened by ADR 0406 §11): GET only, never stored, logged or sent back.
170
+ router.post('/provisioning/instances/:id/render/promote/look', api.requireBuilder, lookupLimit, async (req, res) => {
171
+ const id = parseId(req, res, { positiveInt: true });
172
+ if (!id) return;
173
+ if (validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: 200 } })) return;
174
+ try {
175
+ const inst = await provisioning.getInstanceById(pool, id);
176
+ if (!ownedBy(inst, req.builder)) return res.fail('instance_not_found', 404);
177
+ if (!renderStandup.KEY_RE.test(req.body.key)) return res.fail('render_request_invalid', { status: 400, message: 'That doesn’t look like a Render API key. It starts with “rnd_”.' });
178
+ const why = renderPromote.noPromoteReason(inst);
179
+ if (why) return res.fail('promote_unavailable', { status: 409, message: why });
180
+ const h = inst.app_host;
181
+ const out = await lookupPromotion(req.body.key, { ownerId: h.ownerId, liveServiceId: h.serviceId, sandboxServiceId: h.sandbox.serviceId, fetchImpl: fetchImpl || globalThis.fetch });
182
+ res.json({ ok: true, ...out });
183
+ } catch (err) {
184
+ if (err instanceof RenderLookupError) return res.fail(err.code, { status: LOOKUP_STATUS[err.code] || 502, message: err.message });
185
+ log.error('[provisioning] POST /provisioning/instances/:id/render/promote/look', err && err.message);
186
+ failFrom(res, err, 'render_lookup_failed');
187
+ }
188
+ });
189
+
190
+ // POST /provisioning/instances/:id/render/promote — rank: any authenticated builder, and
191
+ // OWNER ONLY: the key and the live app are the owner's, so the server decides only the
192
+ // owner may move it (an archon on someone else's project gets the stranger's 404). Takes
193
+ // the change the owner chose (`commit_id`) and the change live ran when they looked
194
+ // (`expect_live_commit`); the runner refuses if either moved. This is the ONLY way Bongos
195
+ // moves a live app that has a sandbox, and the reply is a receipt: nothing is live yet.
196
+ router.post('/provisioning/instances/:id/render/promote', api.requireBuilder, sandboxLimit, async (req, res) => {
197
+ const id = parseId(req, res, { positiveInt: true });
198
+ if (!id) return;
199
+ if (validateOrRespond(req, res, {
200
+ key: { required: true, type: 'string', maxLength: 200 },
201
+ commit_id: { required: true, type: 'string', maxLength: 64 },
202
+ expect_live_commit: { type: 'string', maxLength: 64 },
203
+ })) return;
204
+ try {
205
+ const inst = await provisioning.getInstanceById(pool, id);
206
+ if (!ownedBy(inst, req.builder)) return res.fail('instance_not_found', 404);
207
+ if (!renderStandup.KEY_RE.test(req.body.key)) return res.fail('render_request_invalid', { status: 400, message: 'That doesn’t look like a Render API key. It starts with “rnd_”.' });
208
+ const commit = String(req.body.commit_id).trim().toLowerCase();
209
+ const expect = req.body.expect_live_commit == null ? null : String(req.body.expect_live_commit).trim().toLowerCase();
210
+ if (!renderPromote.COMMIT_RE.test(commit) || (expect !== null && !renderPromote.COMMIT_RE.test(expect))) {
211
+ return res.fail('promote_request_invalid', { status: 400, message: 'Choose one of the changes waiting on your sandbox.' });
212
+ }
213
+ const why = renderPromote.noPromoteReason(inst);
214
+ if (why) return res.fail('promote_unavailable', { status: 409, message: why });
215
+ if (!api.secretBox.isConfigured()) {
216
+ return res.fail('render_unavailable', { status: 503, message: 'The platform can’t take a key safely right now, so nothing was sent. Try again later.' });
217
+ }
218
+ const r = await renderPromote.enqueuePromote(pool, id, { keyEnc: api.secretBox.encrypt(req.body.key), commitId: commit, expectLiveCommit: expect, requestedBy: req.builder.id });
219
+ if (r.conflict) {
220
+ return res.fail('intent_conflict', { status: 409,
221
+ message: BUSY_KEY_NOT_KEPT,
222
+ details: { action: r.intent.action, state: r.intent.state } });
223
+ }
224
+ await provisioning.recordEvent(pool, {
225
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'render-promote',
226
+ detail: `promote of ${commit.slice(0, 7)} to the live app requested by owner`, actor: 'api:self',
227
+ }).catch(() => {});
228
+ res.json({ ok: true, queued: true, action: 'render-promote',
229
+ message: 'Queued. That change goes to your live app in a moment; Render then builds it, which usually takes a few minutes. Your key is used once and then thrown away.' });
230
+ } catch (err) {
231
+ // Never log the request: it carries the key.
232
+ log.error('[provisioning] POST /provisioning/instances/:id/render/promote', err && err.message);
233
+ failFrom(res, err, 'render_promote_failed');
234
+ }
235
+ });
236
+
158
237
  // POST /provisioning/instances/:id/render — rank: any authenticated builder, and OWNER
159
238
  // ONLY: the key is the owner's and so is the account it acts in, so an archon on
160
239
  // someone else's project gets the same 404 as a stranger. The reply is a receipt;
@@ -2313,6 +2313,7 @@ summary{min-height:24px;padding:3px 0;}
2313
2313
  }
2314
2314
  if (app.links) v.root.appendChild(appPara([appLink(app.links.service, 'Open it on Render')]));
2315
2315
  sandboxView(data.sandbox, v);
2316
+ promoteView(data.promote, data.sandbox, v);
2316
2317
  var vars = (data.env_vars || []).filter(function (x) { return !x.set_for_you; });
2317
2318
  if (vars.length) {
2318
2319
  v.root.appendChild(appEl('h3', null, 'Still to set on Render'));
@@ -2380,8 +2381,60 @@ summary{min-height:24px;padding:3px 0;}
2380
2381
  }
2381
2382
  v.root.appendChild(box);
2382
2383
  }
2384
+ /* ── promote: a person moves what ran on the sandbox to the live app (task 1005290, ADR 0406) ──
2385
+ The look at what is waiting and the press each take the owner's Render key, held in this
2386
+ closure and the field for the two requests and then dropped. The second press is the
2387
+ confirm: it names the change that goes live. Nothing here moves the live app by itself. */
2388
+ function promoteView(p, sb, v) {
2389
+ if (!p || !sb || ['none', 'requested'].indexOf(sb.state) >= 0) return;
2390
+ var box = appEl('div', 'appPromote');
2391
+ box.appendChild(appEl('h3', null, 'Going live'));
2392
+ box.appendChild(appPara([p.live_follows_main
2393
+ ? 'Your live app still takes every finished change by itself. It stops once your sandbox is running or you promote for the first time.'
2394
+ : 'Your live app changes only when you promote. Look at your sandbox first, then promote what you are happy with.'], 'finePrint'));
2395
+ var last = p.last;
2396
+ if (last && last.state === 'requested') box.appendChild(appPara(['Promoting ' + (last.commit || 'your change') + ' to your live app…']));
2397
+ else if (last && last.state === 'sent') box.appendChild(appPara(['Sent ' + (last.commit || 'your change') + ' to your live app. Render is building it; that usually takes a few minutes.']));
2398
+ else if (last && last.state === 'failed') box.appendChild(appPara([String(last.error || 'The last promotion did not go through.')], 'err'));
2399
+ if (p.can) {
2400
+ v.prBtn = appEl('button', 'btn ghost small appPrBtn', 'See what is waiting to go live');
2401
+ v.prBtn.type = 'button';
2402
+ box.appendChild(v.prBtn);
2403
+ v.prForm = appEl('div', 'appRetry appPrForm');
2404
+ v.prForm.hidden = true;
2405
+ var lab = appEl('label', 'wizField');
2406
+ lab.appendChild(appEl('span', null, 'Render API key'));
2407
+ v.prKey = appEl('input', 'appRetryKey');
2408
+ v.prKey.type = 'password'; v.prKey.autocomplete = 'off'; v.prKey.spellcheck = false; v.prKey.maxLength = 200; v.prKey.placeholder = 'rnd_…';
2409
+ lab.appendChild(v.prKey);
2410
+ v.prForm.appendChild(lab);
2411
+ v.prLook = appEl('button', 'btn small', 'Look');
2412
+ v.prLook.type = 'button';
2413
+ v.prForm.appendChild(v.prLook);
2414
+ v.prList = appEl('div', 'appPrList');
2415
+ v.prForm.appendChild(v.prList);
2416
+ v.prHint = appEl('div', 'hint appRetryHint', 'Your key is used for this look and the promotion, then thrown away.');
2417
+ v.prHint.setAttribute('role', 'status');
2418
+ v.prForm.appendChild(v.prHint);
2419
+ box.appendChild(v.prForm);
2420
+ } else if (p.reason && sb.state !== 'failed') {
2421
+ box.appendChild(appPara([String(p.reason)], 'finePrint'));
2422
+ }
2423
+ if (p.recent && p.recent.length) {
2424
+ box.appendChild(appEl('h4', null, 'Recent promotions'));
2425
+ var ul = appEl('ul', 'appPrRecent');
2426
+ p.recent.forEach(function (r) {
2427
+ var when = r.at ? new Date(r.at).toLocaleString() : '';
2428
+ var what = r.state === 'failed' ? ' — did not go through' : (r.state === 'started' ? ' — being sent' : '');
2429
+ ul.appendChild(appEl('li', null, when + ': ' + r.commit + (r.message ? ' — ' + r.message : '') + what));
2430
+ });
2431
+ box.appendChild(ul);
2432
+ }
2433
+ v.root.appendChild(box);
2434
+ }
2435
+ function promotePending(data) { var l = data && data.promote && data.promote.last; return !!(l && l.state === 'requested'); }
2383
2436
  function sandboxSettled(sb) { return !sb || ['none', 'left', 'removed'].indexOf(sb.state) >= 0 || !!sb.error || sb.stage === 'live' || sb.stage === 'quiet'; }
2384
- function appTerminal(data) { var a = data && data.app; return (!a || a.state === 'disconnected' || !!a.error || a.stage === 'live') && sandboxSettled(data && data.sandbox); }
2437
+ function appTerminal(data) { var a = data && data.app; return (!a || a.state === 'disconnected' || !!a.error || a.stage === 'live') && sandboxSettled(data && data.sandbox) && !promotePending(data); }
2385
2438
  function fetchAppStatus(id) {
2386
2439
  return fetch(API + '/provisioning/instances/' + encodeURIComponent(id) + '/render', { credentials: 'same-origin' })
2387
2440
  .then(function (res) { return res.ok ? res.json() : null; })
@@ -2416,6 +2469,7 @@ summary{min-height:24px;padding:3px 0;}
2416
2469
  function tick() { fetchAppStatus(id).then(paint); }
2417
2470
  function wire(v) {
2418
2471
  if (v.sbBtn) wireSandbox(v);
2472
+ if (v.prBtn) wirePromote(v);
2419
2473
  if (!v.retryBtn) return;
2420
2474
  v.retryBtn.addEventListener('click', function () {
2421
2475
  v.retryForm.hidden = false; v.retryBtn.hidden = true; v.keyEl.focus();
@@ -2445,6 +2499,50 @@ summary{min-height:24px;padding:3px 0;}
2445
2499
  });
2446
2500
  });
2447
2501
  }
2502
+ function wirePromote(v) {
2503
+ var key = '';
2504
+ function hint(t, bad) { v.prHint.textContent = t; v.prHint.className = 'hint appRetryHint' + (bad ? ' err' : ''); }
2505
+ function send(path, body) {
2506
+ return fetch(API + '/provisioning/instances/' + encodeURIComponent(id) + path, {
2507
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
2508
+ }).then(function (res) {
2509
+ return res.json().catch(function () { return {}; }).then(function (d) {
2510
+ if (!res.ok) throw new Error((d.error && d.error.message) || 'That did not go through.');
2511
+ return d;
2512
+ });
2513
+ });
2514
+ }
2515
+ function drop() { key = ''; v.prKey.value = ''; }
2516
+ v.prBtn.addEventListener('click', function () { v.prForm.hidden = false; v.prBtn.hidden = true; v.prKey.focus(); });
2517
+ v.prLook.addEventListener('click', function () {
2518
+ key = String(v.prKey.value || '').trim();
2519
+ if (!/^rnd_[A-Za-z0-9]{16,128}$/.test(key)) { key = ''; hint('That doesn’t look like a Render API key. It starts with “rnd_”.', true); return; }
2520
+ v.prLook.disabled = true; hint('Asking Render…');
2521
+ send('/render/promote/look', { key: key }).then(function (d) {
2522
+ v.prList.textContent = ''; v.prLook.disabled = false;
2523
+ v.prList.appendChild(appPara([d.live ? 'Live now: ' + d.live.short + (d.live.message ? ' — ' + d.live.message : '') : 'Nothing is live on your app yet.']));
2524
+ if (!d.waiting || !d.waiting.length) { drop(); hint('Nothing new is waiting: your live app already runs what your sandbox runs.'); return; }
2525
+ var ul = appEl('ul', 'appPrWaiting');
2526
+ d.waiting.forEach(function (w) { ul.appendChild(appEl('li', null, w.short + (w.message ? ' — ' + w.message : ''))); });
2527
+ v.prList.appendChild(appPara(['Waiting on your sandbox, newest first:']));
2528
+ v.prList.appendChild(ul);
2529
+ var go = appEl('button', 'btn small appPrGo', 'Promote ' + d.waiting[0].short + ' to my live app');
2530
+ go.type = 'button';
2531
+ v.prList.appendChild(go);
2532
+ hint('Promoting the newest brings everything listed above.');
2533
+ go.addEventListener('click', function () {
2534
+ if (!go.dataset.sure) { go.dataset.sure = '1'; go.textContent = 'Yes, put ' + d.waiting[0].short + ' live'; hint('Press again to confirm. Your live app will change.'); return; }
2535
+ go.disabled = true; hint('Sending…');
2536
+ var body = { key: key, commit_id: d.waiting[0].commit_id };
2537
+ if (d.live && d.live.commit_id) body.expect_live_commit = d.live.commit_id;
2538
+ send('/render/promote', body).then(function (r) {
2539
+ body.key = ''; drop(); hint(r.message || 'Queued.');
2540
+ v.prForm.hidden = true; view = null; clearTimeout(t); ticks = 0; tick();
2541
+ }).catch(function (err) { body.key = ''; drop(); go.disabled = false; hint(err.message + ' Enter your key again to retry.', true); });
2542
+ });
2543
+ }).catch(function (err) { drop(); v.prLook.disabled = false; hint(err.message, true); });
2544
+ });
2545
+ }
2448
2546
  function wireSandbox(v) {
2449
2547
  v.sbBtn.addEventListener('click', function () { v.sbForm.hidden = false; v.sbBtn.hidden = true; v.sbKey.focus(); });
2450
2548
  v.sbSend.addEventListener('click', function () {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.20",
3
+ "version": "1.25.21",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.20",
9
+ "version": "1.25.21",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.20",
3
+ "version": "1.25.21",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11868,5 +11868,23 @@
11868
11868
  "id": "1005274",
11869
11869
  "text": "A founder who planned their project with the guide now finds the project form and the first Genesis question already filled in, plus a plan file for their own AI."
11870
11870
  }
11871
+ ],
11872
+ "1.25.21": [
11873
+ {
11874
+ "id": "1004162",
11875
+ "text": "Someone already connected to a hosted project who can't manage it is now told what to do next (apply to join, accept their invite, or ask the owner) instead of \"not found\"; strangers still learn nothing, and private projects s"
11876
+ },
11877
+ {
11878
+ "id": "1005130",
11879
+ "text": "When founding a project, the charter step now tells the founder the project is already a monarchy, what every form of government will ask of them day to day, and plainly when a choice changes nothing."
11880
+ },
11881
+ {
11882
+ "id": "1004161",
11883
+ "text": "A project owner can now give a named teammate access to help run their hosted project (view it, restart it, keep its settings list) and take it back; signing in alone still gives nobody access."
11884
+ },
11885
+ {
11886
+ "id": "1005290",
11887
+ "text": "Finished work now reaches a project's test copy first; the owner sees what is waiting and presses promote to put it on the live site, and every promotion is recorded."
11888
+ }
11871
11889
  ]
11872
11890
  }
@@ -58,7 +58,17 @@ function formatStage(s) {
58
58
  // Pure: the charter stage's recommendation from the founder's answers (task 1005119).
59
59
  // A recommendation only: choosing is still the founder's.
60
60
  function formatAdvice(a) {
61
- const out = [` Recommended government: ${a.charter.name}`, ` Why: ${a.charter.why}`];
61
+ const out = [];
62
+ // task 1005130: what is in force now, and what each form asks of you day to day.
63
+ if (a.in_force) out.push(` In force now: ${a.in_force.line}`);
64
+ if (a.forms) {
65
+ out.push(' The forms you can choose:');
66
+ for (const f of a.forms.filter((x) => x.available)) {
67
+ out.push(` ${f.name}${f.current ? ' (in force now)' : ''}: ${f.who_decides} ${f.to_close}`);
68
+ }
69
+ }
70
+ out.push(` Recommended government: ${a.charter.name}`, ` Why: ${a.charter.why}`);
71
+ if (a.charter.on_choose) out.push(` ${a.charter.on_choose.line}`);
62
72
  if (a.charter.council_rank) out.push(` It is governed by a council rank you create: ${a.charter.council_rank}.`);
63
73
  out.push(' It is a recommendation. Any form, or your own settings, is still yours to choose.');
64
74
  if (a.preset) {
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+ // scripts/bongos/government-walk-sole.js — the government walk's board-of-one rows
3
+ // (task 1005130, part C). Its own file so government-walk.js stays under the size
4
+ // ratchet; the walk calls it with its own helpers, so the rows run through the same
5
+ // real routers and in-memory store as every other row.
6
+ //
7
+ // WHY. A board of one decides by filing: when the sole member of a first_ratifier
8
+ // board files an amendment, their yes is cast in the same call (sole-member.js).
9
+ // These rows prove the other side of that rule through the real routes: a lower rank
10
+ // is refused by the route's own permission gate before anything is filed, and a
11
+ // filing by a builder the board does not seat — a charter change and a rules change —
12
+ // waits for the board with no vote cast.
13
+
14
+ // What a filing decided in the same call, when a board of one decided it.
15
+ const decidedOf = (r) => (r.json && r.json.decided) || {};
16
+
17
+ // The genesis pick was adopted by filing: the item passed, and the vote that passed it
18
+ // is the founder's own, recorded as cast by filing.
19
+ function adoptedByFiling({ store, stateOf, itemId, voterId }) {
20
+ const via = (store.votes.find((v) => v.item_id === String(itemId) && v.voter_id === String(voterId)) || {}).cast_via || 'none';
21
+ return { got: `${stateOf(itemId)}; the founder's yes, cast by ${via === 'filed' ? 'filing' : via}`, pass: stateOf(itemId) === 'passed' && via === 'filed' };
22
+ }
23
+
24
+ async function soleMemberRows({ check, call, vote, code, store, stateOf, itemIdOf, ids: { EVE, BEN, FOUNDER_ID }, SHORT_LAW }) {
25
+ await check('A builder without the right to propose is refused before anything is filed', '403; nothing filed', async () => {
26
+ const before = store.items.length;
27
+ const r = await call(EVE, 'POST', '/government/board/laws', { enact: [SHORT_LAW] });
28
+ return { got: `${r.status}; ${store.items.length === before ? 'nothing filed' : 'a sitting was opened'}`, pass: r.status === 403 && store.items.length === before };
29
+ });
30
+ await check('A charter and a rules change filed by a builder the board does not seat wait for the board', 'both sitting open; no vote cast', async () => {
31
+ const c = await call(BEN, 'POST', '/government/board/charter', { form: 'direct-democracy' });
32
+ const d = await call(BEN, 'POST', '/government/board/decision-rules', { rules: [{ area: 'review', decided_by: 'rank:council', rule: 'majority' }] });
33
+ const items = [itemIdOf(c), itemIdOf(d)];
34
+ const open = items.every((id) => id && stateOf(id) === 'open');
35
+ const cast = store.votes.filter((v) => items.includes(v.item_id)).length;
36
+ for (const id of items) if (id) await vote(FOUNDER_ID, id, 'no'); // the board returns both, so nothing is left standing
37
+ return { got: `${open ? 'both sitting open' : `${c.status} ${code(c)} ${stateOf(items[0])}, ${d.status} ${code(d)} ${stateOf(items[1])}`}; ${cast ? `${cast} vote cast` : 'no vote cast'}`, pass: open && cast === 0 };
38
+ });
39
+ }
40
+
41
+ module.exports = { decidedOf, adoptedByFiling, soleMemberRows };
@@ -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 };