@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
@@ -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');
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.20'; // 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 () => {};
@@ -131,7 +131,11 @@ test('picking Monarchy files nothing: it is already the government in force', as
131
131
  const seen = await underMonarchy(async () => {
132
132
  const { out } = await decide({ government: 'monarchy' });
133
133
  assert.equal(out.ok, true);
134
- assert.deepEqual(out.charter, { state: 'in_force', form: 'monarchy' });
134
+ assert.equal(out.charter.state, 'in_force');
135
+ assert.equal(out.charter.form, 'monarchy');
136
+ // task 1005130: said plainly, and counted as done — never a silent no-op
137
+ assert.equal(out.charter.complete, true);
138
+ assert.match(out.charter.message, /already a Monarchy \/ BDFL\. Nothing to change/);
135
139
  });
136
140
  assert.equal(seen.filed, null);
137
141
  });
@@ -573,6 +573,23 @@ test('a demo\'s genesis card: no decide form, the settled plan in one line, each
573
573
  assert.doesNotMatch(host.innerHTML, /data-genesis-plan/, 'a demo never shows the decide form');
574
574
  });
575
575
 
576
+ test('the plan picker says what is in force, marks it, and says what each form asks day to day (task 1005130)', async () => {
577
+ const charter = require('../modules/government/charter.js');
578
+ const reco = require('../modules/government/recommendations.js');
579
+ const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
580
+ const genesis = gh.shapeGenesis({ mode: { stage: 'decide', stage_number: 0, stage_count: 5, day: 1 }, plan: null });
581
+ // One stub answers every read: the genesis read and the constitution read.
582
+ const { host } = bootCard({ founding: true, data: {
583
+ genesis, viewer_id: '1', headcount: 1, charter: charter.charterView(MONARCHY), recommended: reco.recommendedCharter(1),
584
+ } });
585
+ for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r));
586
+ assert.match(host.innerHTML, /This project runs as Monarchy \/ BDFL now\. Every project opens as one/, 'what is in force is said first');
587
+ assert.match(host.innerHTML, /Monarchy \/ BDFL · in force now · recommended/, 'the form in force is marked');
588
+ assert.match(host.innerHTML, /You decide\. Each decision takes one yes from you\./, 'monarchy says one yes is still needed');
589
+ assert.match(host.innerHTML, /Every builder on the project decides\./, 'every form says who decides');
590
+ assert.equal((host.innerHTML.match(/in force now/g) || []).length, 1, 'only the form in force is marked');
591
+ });
592
+
576
593
  test('the card lists every stage vote waiting on the viewer, and votes yes on all in order (task 1005199)', async () => {
577
594
  const me = [{ builder_id: '1', display_name: 'Founder' }];
578
595
  const genesis = gh.shapeGenesis({
@@ -148,3 +148,52 @@ test('the port entry reads the head count and the law book', async () => {
148
148
  assert.equal(expected.headcount, 2, 'the founder\'s expected number wins over the live count');
149
149
  assert.equal(expected.charter.key, 'monarchy');
150
150
  });
151
+
152
+ // ── task 1005130: every project is already a monarchy, and is told so ────────
153
+ const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
154
+ const config = require(path.join(ROOT, 'modules', 'government', 'config.js'));
155
+
156
+ test('the default constitution reads as Monarchy, and the stage says so on arrival', () => {
157
+ const live = config.loadGovernmentConfig().board;
158
+ const v = charter.charterView(live);
159
+ assert.equal(v.in_force.form, 'monarchy', 'every project boots as a monarchy');
160
+ assert.match(v.in_force.line, /runs as Monarchy \/ BDFL now\. Every project opens as one/);
161
+ assert.match(v.in_force.line, /You decide\. Each decision takes one yes from you\./);
162
+ assert.deepEqual(v.forms.filter((f) => f.current).map((f) => f.key), ['monarchy'], 'exactly the form in force is marked');
163
+ });
164
+
165
+ test('every form says who decides and what closing takes, derived from its own settings', () => {
166
+ const v = charter.charterView(MONARCHY);
167
+ for (const f of v.forms.filter((x) => x.available)) {
168
+ const d = charter.dutyOf(f.franchise, f.pass_rule);
169
+ assert.equal(f.who_decides, d.who_decides, f.key);
170
+ assert.equal(f.to_close, d.to_close, f.key);
171
+ assert.ok(f.who_decides && f.to_close, `${f.key} says both`);
172
+ }
173
+ assert.equal(charter.dutyOf('founder', 'first_ratifier').to_close, 'Each decision takes one yes from you.');
174
+ assert.match(charter.dutyOf('everyone', 'majority').to_close, /more than half/);
175
+ assert.notEqual(charter.dutyOf('trusted', 'consent').to_close, charter.dutyOf('trusted', 'majority').to_close,
176
+ 'the sentence follows the pass rule, so a tuned rule is described as it runs');
177
+ });
178
+
179
+ test('choosing what is in force changes nothing and is done; choosing another says what happens next', () => {
180
+ const same = charter.choiceOutcome(MONARCHY, 'monarchy');
181
+ assert.equal(same.changes, false);
182
+ assert.equal(same.complete, true);
183
+ assert.match(same.line, /already a Monarchy \/ BDFL\. Nothing to change/);
184
+ const other = charter.choiceOutcome(MONARCHY, 'direct-democracy');
185
+ assert.equal(other.changes, true);
186
+ assert.match(other.line, /filed as a change to the rules and decided under the government in force \(Monarchy \/ BDFL\)\. Under it, your own one yes applies it\./);
187
+ const underCircle = charter.choiceOutcome({ ...MONARCHY, membership: 'rank:xenos+', pass_rule: 'consent' }, 'monarchy');
188
+ assert.match(underCircle.line, /passes unless one of them objects/, 'under another government the rule in force is named');
189
+ });
190
+
191
+ test('the charter stage advice carries what is in force and what choosing the recommendation does', () => {
192
+ const a = advice.buildAdvice({ answers: {}, headcount: 1, board: MONARCHY });
193
+ assert.equal(a.in_force.form, 'monarchy');
194
+ assert.equal(a.charter.key, 'monarchy');
195
+ assert.equal(a.charter.on_choose.changes, false, 'a solo founder is told the recommendation is what they already have');
196
+ const b = advice.buildAdvice({ answers: { final_say: 'everyone' }, headcount: 1, board: MONARCHY });
197
+ assert.equal(b.charter.on_choose.changes, true);
198
+ assert.ok(b.forms.find((f) => f.key === 'monarchy').current);
199
+ });
@@ -254,8 +254,12 @@ test('a charter that changes nothing files nothing, and a council must be a rank
254
254
  getRankByKey: async (key) => (key === 'trustees' ? { rank_key: 'trustees' } : null),
255
255
  builderHoldsRankActive: async () => true,
256
256
  }, async () => {
257
- assert.equal((await board.proposeCharter({ form: 'monarchy', proposedBy: '42' })).reason, 'no_change',
258
- 'a sitting on the constitution already in force would decide nothing');
257
+ const same = await board.proposeCharter({ form: 'monarchy', proposedBy: '42' });
258
+ assert.equal(same.reason, 'no_change', 'a sitting on the constitution already in force would decide nothing');
259
+ // task 1005130: the refusal says what is true in words and counts the choice done
260
+ assert.equal(same.in_force, 'Monarchy / BDFL');
261
+ assert.equal(same.complete, true);
262
+ assert.match(same.message, /You are already a Monarchy \/ BDFL\. Nothing to change/);
259
263
  assert.equal((await board.proposeCharter({ form: 'foundation', councilRank: 'ghosts', proposedBy: '42' })).reason, 'unknown_council_rank');
260
264
  assert.equal((await board.proposeCharter({ form: 'direct-democracy', proposedBy: null })).reason, 'missing_proposer');
261
265
  });
@@ -272,7 +276,14 @@ test('the constitution view carries the charter: forms, variables, and what the
272
276
  });
273
277
  const routes = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
274
278
  assert.match(routes, /router\.post\('\/government\/board\/charter', api\.requireBuilder, api\.requirePermission\('board\.item\.open'\)/);
275
- assert.match(routes, /proposeCharter: board\.proposeCharter/);
279
+ // The port's charter filing IS board.proposeCharter, followed by the board-of-one
280
+ // confirm (task 1005130) — called, not read: a refusal from board comes back as is.
281
+ const { governmentPort } = require(path.join(GOV, 'routes', 'government.js'));
282
+ const real = board.proposeCharter;
283
+ board.proposeCharter = async (a) => ({ ok: false, reason: 'from_board', seen: a.form });
284
+ try {
285
+ assert.deepEqual(await governmentPort.proposeCharter({ form: 'monarchy', proposedBy: '42' }), { ok: false, reason: 'from_board', seen: 'monarchy' });
286
+ } finally { board.proposeCharter = real; }
276
287
  const pins = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'), 'utf8');
277
288
  assert.match(pins, /'POST \/government\/board\/charter': 'perm:board\.item\.open'/);
278
289
  });
@@ -55,6 +55,7 @@ const NON_GRANT_MIGRATIONS = new Set([
55
55
  'government_036_access_holders.sql', // who holds the keys outside Bongos + the access:<key> ask seat (task 1005000)
56
56
  'government_037_constitution.sql', // the constitution in force as a durable row (task 1005140)
57
57
  'government_038_vote_cast_via.sql', // HOW a vote was cast: a ballot, or a stage-close confirm (task 1005121)
58
+ 'government_039_vote_cast_via_filed.sql', // a board of one's confirm given by filing (task 1005130)
58
59
  ]);
59
60
 
60
61
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -0,0 +1,153 @@
1
+ // tests/government_sole_member.mjs — a board of ONE decides by filing (task 1005130,
2
+ // part C; the ruling task 1005121 shipped for closing a genesis stage, applied
3
+ // wherever it holds).
4
+ //
5
+ // The rule held here, for a charter change, a laws change and a decision-rules change:
6
+ // • the sole member of a first_ratifier board files → their yes is cast in the same
7
+ // call, recorded as THEIR vote (attributed, cast_via 'filed'), and the sitting
8
+ // closes through the ordinary close path;
9
+ // • a second member on the board, a consent or majority rule, or a caller who is not
10
+ // the sole member → the filing comes back exactly as it was, and nothing is cast;
11
+ // • the yes goes through the real castVote — its membership re-check, and a
12
+ // 'filed' cast is refused on anything but an amendment, or as a 'no';
13
+ // • a filing the filer was refused comes back untouched, and a failing confirm
14
+ // leaves the sitting open rather than failing the filing.
15
+ // The real routes (permission gate first, a non-member's filing left open, a lower
16
+ // rank refused before anything is filed) are walked end to end in
17
+ // scripts/bongos/government-walk.js (rows G3, G5, B*, L2–L4, L11–L15).
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import { createRequire } from 'node:module';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const GOV = path.join(ROOT, 'modules', 'government');
27
+ const db = require(path.join(GOV, 'db.js'));
28
+ const board = require(path.join(GOV, 'board.js'));
29
+ const sole = require(path.join(GOV, 'sole-member.js'));
30
+
31
+ const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
32
+ const FILERS = ['proposeCharter', 'proposeLaws', 'proposeDecisionRules'];
33
+
34
+ // A world: who holds which rank, the one open item a filing opened, and every vote
35
+ // written. db is stubbed underneath the REAL castVote; the filers and the close are
36
+ // stubbed on board (looked up at call time), so this exercises the decision alone.
37
+ async function world({ members = ['42'], constitution = MONARCHY, subjectType = 'constitutional_amendment', filedOk = true } = {}, fn) {
38
+ const votes = [];
39
+ const closes = [];
40
+ const item = { id: 7, subject_type: subjectType, constitution, closed_at: null, expired: false };
41
+ const stubs = {
42
+ getActiveBuildersWithRank: async () => members.map((id) => ({ builder_id: id, github_login: `b${id}`, display_name: `B${id}` })),
43
+ builderHoldsRankActive: async (id) => members.includes(String(id)),
44
+ getBoardItemForVote: async () => ({ ...item }),
45
+ getBoardItemAuthor: async () => '42',
46
+ insertBoardVote: async (v) => { const row = { item_id: String(v.itemId), voter_id: String(v.voterId), direction: v.direction, cast_via: v.castVia || 'ballot', cast_at: '2026-11-13T12:00:00.000Z' }; votes.push(row); return row; },
47
+ getBoardVote: async () => null,
48
+ insertVoteKarma: async () => { throw new Error('a confirm given by filing earns no vote karma'); },
49
+ };
50
+ const boardStubs = {
51
+ maybeCloseAfterVote: async (id) => { closes.push(id); return { closed: true, outcome: 'passed', item: { id } }; },
52
+ };
53
+ for (const f of FILERS) boardStubs[f] = async () => (filedOk ? { ok: true, amendment: { id: 3 }, item: { ...item } } : { ok: false, reason: 'proposer_would_lose_their_seat' });
54
+ const saved = {};
55
+ for (const [k, v] of Object.entries(stubs)) { saved[`db.${k}`] = db[k]; db[k] = v; }
56
+ for (const [k, v] of Object.entries(boardStubs)) { saved[`board.${k}`] = board[k]; board[k] = v; }
57
+ try {
58
+ return await fn({ votes, closes });
59
+ } finally {
60
+ for (const [k, v] of Object.entries(saved)) {
61
+ const [obj, key] = k.split('.');
62
+ (obj === 'db' ? db : board)[key] = v;
63
+ }
64
+ }
65
+ }
66
+
67
+ for (const filer of FILERS) {
68
+ test(`${filer}: the sole member of a one-yes board decides by filing, and the vote is theirs on the record`, async () => {
69
+ await world({}, async ({ votes, closes }) => {
70
+ const out = await sole[filer]({ proposedBy: '42' });
71
+ assert.equal(out.ok, true);
72
+ assert.deepEqual(out.decided, { closed: true, outcome: 'passed', cast_via: 'filed' });
73
+ assert.equal(votes.length, 1);
74
+ assert.equal(votes[0].voter_id, '42', 'attributed to the member who filed');
75
+ assert.equal(votes[0].direction, 'yes');
76
+ assert.equal(votes[0].cast_via, 'filed', 'the record says how it was cast');
77
+ assert.ok(votes[0].cast_at, 'and when');
78
+ assert.deepEqual(closes, [7], 'closed through the ordinary close path');
79
+ });
80
+ });
81
+
82
+ test(`${filer}: a second member on the board — nothing is cast, the sitting waits`, async () => {
83
+ await world({ members: ['42', '43'] }, async ({ votes, closes }) => {
84
+ const out = await sole[filer]({ proposedBy: '42' });
85
+ assert.equal(out.decided, undefined);
86
+ assert.deepEqual(votes, []);
87
+ assert.deepEqual(closes, []);
88
+ });
89
+ });
90
+
91
+ for (const rule of ['consent', 'majority', 'unanimous']) {
92
+ test(`${filer}: under ${rule} a sole member's filing still waits for their vote`, async () => {
93
+ await world({ constitution: { ...MONARCHY, pass_rule: rule } }, async ({ votes, closes }) => {
94
+ const out = await sole[filer]({ proposedBy: '42' });
95
+ assert.equal(out.decided, undefined);
96
+ assert.deepEqual(votes, []);
97
+ assert.deepEqual(closes, []);
98
+ });
99
+ });
100
+ }
101
+
102
+ test(`${filer}: a caller who is not the sole member — the sitting is left open, exactly as today`, async () => {
103
+ await world({ members: ['42'] }, async ({ votes, closes }) => {
104
+ const out = await sole[filer]({ proposedBy: '43' });
105
+ assert.equal(out.ok, true);
106
+ assert.equal(out.decided, undefined);
107
+ assert.deepEqual(votes, []);
108
+ assert.deepEqual(closes, []);
109
+ });
110
+ });
111
+
112
+ test(`${filer}: a refused filing comes back untouched, and nothing is cast`, async () => {
113
+ await world({ filedOk: false }, async ({ votes }) => {
114
+ assert.deepEqual(await sole[filer]({ proposedBy: '42' }), { ok: false, reason: 'proposer_would_lose_their_seat' });
115
+ assert.deepEqual(votes, []);
116
+ });
117
+ });
118
+ }
119
+
120
+ test('the yes goes through the real castVote: membership is re-checked under the item\'s snapshot', async () => {
121
+ // resolve says 42 is the one member, but the per-request re-check says 42 no longer
122
+ // holds the rank (demoted between the two reads): the cast is refused, nothing closes.
123
+ await world({}, async ({ votes, closes }) => {
124
+ db.builderHoldsRankActive = async () => false;
125
+ const out = await sole.proposeLaws({ proposedBy: '42' });
126
+ assert.equal(out.decided, undefined);
127
+ assert.deepEqual(votes, []);
128
+ assert.deepEqual(closes, []);
129
+ });
130
+ });
131
+
132
+ test('a cast marked "filed" is refused on anything but an amendment, and as a no', async () => {
133
+ await world({ subjectType: 'full_idea' }, async ({ votes }) => {
134
+ assert.equal((await board.castVote({ itemId: 7, voterId: '42', direction: 'yes', castVia: 'filed' })).reason, 'bad_cast_via');
135
+ assert.deepEqual(votes, []);
136
+ });
137
+ await world({ subjectType: 'genesis_stage' }, async () => {
138
+ assert.equal((await board.castVote({ itemId: 7, voterId: '42', direction: 'yes', castVia: 'filed' })).reason, 'bad_cast_via');
139
+ });
140
+ await world({}, async () => {
141
+ assert.equal((await board.castVote({ itemId: 7, voterId: '42', direction: 'no', reasonMd: 'no', sectionKey: 'purpose', castVia: 'filed' })).reason, 'bad_cast_via');
142
+ assert.equal((await board.castVote({ itemId: 7, voterId: '42', direction: 'yes', castVia: 'by_magic' })).reason, 'bad_cast_via');
143
+ });
144
+ });
145
+
146
+ test('a confirm that fails leaves the filing standing and the sitting open', async () => {
147
+ await world({}, async () => {
148
+ board.maybeCloseAfterVote = async () => { throw new Error('db down'); };
149
+ const out = await sole.proposeCharter({ proposedBy: '42' });
150
+ assert.equal(out.ok, true);
151
+ assert.equal(out.decided, undefined);
152
+ });
153
+ });
@@ -50,6 +50,8 @@ let published = [];
50
50
  let healed = [];
51
51
 
52
52
  provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
53
+ // The gate also admits an owner-made grant (task 1004161, ADR 0407); nobody in this file holds one.
54
+ require('../modules/provisioning/grants.js').liveGrant = async () => null;
53
55
  provisioning.getInstanceBySlug = async (_db, slug) => (instance && instance.slug === slug ? { ...instance } : null);
54
56
  provisioning.getInstanceByDomain = async () => null;
55
57
  provisioning.listInstancesForOwner = async () => []; // the shape gate's count (task 1004412): no other hosted project
@@ -235,6 +235,10 @@ console.log('\nthe routes:');
235
235
 
236
236
  let instance = null;
237
237
  provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
238
+ // The routes also admit an owner-made grant (task 1004161) and audit each change: nobody here
239
+ // holds a grant, and the audit rows are not this file's subject.
240
+ require('../modules/provisioning/grants.js').liveGrant = async () => null;
241
+ provisioning.recordEvent = async () => {};
238
242
 
239
243
  const api = require('../src/module-api.js');
240
244
  let builder = { id: 7, rank: 'xenos', github_login: 'owner' };
@@ -32,6 +32,8 @@ const express = require('express');
32
32
 
33
33
  const api = require('../src/module-api.js');
34
34
  const provisioning = require('../modules/provisioning/provisioning.js');
35
+ // The gate also admits an owner-made grant (task 1004161, ADR 0407); nobody in this file holds one.
36
+ require('../modules/provisioning/grants.js').liveGrant = async () => null;
35
37
  const envManifest = require('../modules/provisioning/env-manifest.js');
36
38
  const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
37
39