@bongos/core 1.19.936 → 1.19.938

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 (43) hide show
  1. package/.bongos-core.json +69 -44
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +2 -0
  8. package/docs/api/openapi.json +91 -2
  9. package/docs/api-reference.md +3 -2
  10. package/docs/module-api-changelog.md +4 -0
  11. package/docs/recipes/self-host.md +1 -1
  12. package/modules/government/board.js +31 -0
  13. package/modules/government/db.js +33 -0
  14. package/modules/government/migrations/government_018_passed_items_index.sql +31 -0
  15. package/modules/government/routes/government.js +7 -0
  16. package/modules/hall-ui/public/deploy.js +1 -1
  17. package/modules/ideas/inbox.js +11 -0
  18. package/modules/ideas/migrations/ideas_010_goal_ratification.sql +103 -0
  19. package/modules/ideas/module.json +1 -0
  20. package/modules/ideas/ratify.js +288 -1
  21. package/modules/ideas/routes/ratify-goal.js +163 -0
  22. package/modules/ideas/templates.js +16 -4
  23. package/modules/lifecycle/goal-from-idea.js +131 -0
  24. package/modules/lifecycle/lifecycle.js +14 -0
  25. package/modules/provisioning/core-upgrade.js +1 -1
  26. package/modules/provisioning/routes/core-upgrade.js +1 -1
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/audit-authorship.sh +33 -0
  30. package/scripts/gds/db-backup-nightly.sh +1 -1
  31. package/scripts/gds/instance-apex.js +11 -1
  32. package/scripts/gds/provision-config.js +3 -2
  33. package/scripts/gds/provision-core-upgrade.js +7 -7
  34. package/scripts/gds/provision-process.js +2 -2
  35. package/scripts/gds/provision-repo.js +35 -10
  36. package/scripts/gds/provision-units.js +3 -3
  37. package/scripts/gds/provision.js +6 -3
  38. package/src/module-api.js +1 -1
  39. package/tests/core_upgrade_runner.mjs +10 -7
  40. package/tests/government_seed.mjs +1 -0
  41. package/tests/idea_goal_ratification.mjs +553 -0
  42. package/tests/provision.mjs +44 -7
  43. package/tests/provision_account_identity.mjs +16 -0
@@ -14,7 +14,7 @@ const crypto = require('node:crypto');
14
14
  const fs = require('node:fs');
15
15
  const os = require('node:os');
16
16
  const path = require('node:path');
17
- const { CONFIG, REPO_ROOT, coreVersionSafe, provisionerBotEmail } = require('./provision-config.js');
17
+ const { CONFIG, REPO_ROOT, coreVersionSafe, provisionerBotEmail, provisionerBotName } = require('./provision-config.js');
18
18
  // The repo-identity + owner-credential leaf (task 1003081). Carved OUT of this file so
19
19
  // upgrade.js can reach these three without pulling provision*.js into the merge
20
20
  // resolver's executed closure; re-exported below so every existing caller is unchanged.
@@ -86,10 +86,34 @@ function seedFirstVersionCmd(inst, { privileged = false } = {}) {
86
86
  // It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
87
87
  // scripts/deploy/deploy-instance.sh runbook API-driven instead of by hand.
88
88
  const CORE_PKG_DIR = 'node_modules/@bongos/core';
89
- // The unix account the instance's Node process + its backup timer run as. Mirrors
90
- // serviceUnit's hardcoded `User=lars`/`Group=lars` (the droplet's app user); kept as
91
- // one constant so the backup dir owner + the backup unit's User= can't drift from it.
92
- const APP_USER = 'lars';
89
+ // The plane's app user: the unix account that owns the control-plane checkout, owns the
90
+ // backup dir, runs the backup timers, and whose GROUP every instance account joins to read
91
+ // the checkout. One resolver, so those four can't drift apart.
92
+ //
93
+ // Derived, never named (task 1002719). It used to be the founder's own account as a
94
+ // literal, and a standup on any host that account did not exist on died `invalid user` at
95
+ // the backup step. PROVISION_APP_USER pins it; otherwise it is the account this runner
96
+ // executes as, which on a plane IS the app user (the runner unit runs as it), so the live
97
+ // box resolves exactly what the literal said. A runner driving a different box over SSH
98
+ // (remoteHost) should pin it. root is refused, not resolved: the instance
99
+ // accounts join this account's group, and group root is not a read grant to hand a tenant.
100
+ // Validated because it is interpolated into shell commands and unit files.
101
+ const UNIX_ACCOUNT_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
102
+ function appUser({ env = process.env, userInfo = os.userInfo } = {}) {
103
+ let name = String(env.PROVISION_APP_USER || '').trim();
104
+ if (!name) {
105
+ try { name = userInfo().username; } catch { name = ''; } // a uid with no passwd entry
106
+ }
107
+ if (name === 'root' || !UNIX_ACCOUNT_RE.test(name)) {
108
+ const why = name === 'root' ? 'is root' : 'is not a usable unix account name';
109
+ throw new Error(`provisioning: the app user ${JSON.stringify(name)} ${why} — set PROVISION_APP_USER to the unprivileged account that owns this checkout`);
110
+ }
111
+ return name;
112
+ }
113
+ // The app user's PRIMARY group, looked up by the shell on the box the command runs on. It
114
+ // need not share the account's name (a service account created into `users`), so it is
115
+ // never spelled as the account name.
116
+ function appGroupExpr() { return `$(id -gn ${appUser()})`; }
93
117
  function standaloneRoot(inst, { base = CONFIG.standaloneBase } = {}) { return path.posix.join(base, inst.slug); }
94
118
  // WHERE THE INSTANCE THIS CORE SERVES ACTUALLY LIVES (task 1004123).
95
119
  //
@@ -145,7 +169,7 @@ function standaloneRegenDocsCmd() { return `node ${CORE_PKG_DIR}/scripts/gds/reg
145
169
  // ── Per-instance identity: its OWN unix account + its OWN password-authed PG role ──
146
170
  // (task 1003369, audit ref B3 of the 2026-08-29 security audit.)
147
171
  //
148
- // THE HOLE THIS CLOSES. Every instance service used to run `User=lars/Group=lars`, and
172
+ // THE HOLE THIS CLOSES. Every instance service used to run as the one shared app user, and
149
173
  // co-tenant is the DEFAULT shape — so on a shared box every instance ran as the SAME
150
174
  // unix user. A unix user may read /proc/<pid>/environ of its own processes, which means
151
175
  // any instance could read every SIBLING's environment: GITHUB_APP_PRIVATE_KEY, the
@@ -223,7 +247,7 @@ function ensureInstanceUserCmd(inst, { privileged = false } = {}) {
223
247
  // NOT re-open the two vectors this task closes: /proc/<pid>/environ stays readable only
224
248
  // by the process's own uid, and the DB now demands a password no sibling holds.
225
249
  function grantInstanceRepoReadCmd(inst, { privileged = false } = {}) {
226
- return `${privileged ? 'sudo ' : ''}usermod -aG ${APP_USER} ${instanceUser(inst)}`;
250
+ return `${privileged ? 'sudo ' : ''}usermod -aG ${appGroupExpr()} ${instanceUser(inst)}`;
227
251
  }
228
252
 
229
253
  // Can this plane give a child instance its OWN unix account AT ALL? (task 1004018, ADR 0317)
@@ -758,7 +782,7 @@ async function scaffoldStandaloneRepo(inst, deps) {
758
782
  } else {
759
783
  writeFile(giPath, INSTANCE_GITIGNORE);
760
784
  }
761
- exec(`git -C ${root} config user.name "Cloud Bongos"`);
785
+ exec(`git -C ${root} config user.name "${provisionerBotName()}"`);
762
786
  exec(`git -C ${root} config user.email "${provisionerBotEmail()}"`);
763
787
  exec(`git -C ${root} add -A`);
764
788
  const commitMsg = mode === 'adopt'
@@ -1012,7 +1036,7 @@ function buildCorePinRefreshCommit({ root, fromVersion, toVersion, repo, ownerTo
1012
1036
  : `git -C ${root} push origin main`;
1013
1037
  return {
1014
1038
  config: [
1015
- `git -C ${root} config user.name "Cloud Bongos"`,
1039
+ `git -C ${root} config user.name "${provisionerBotName()}"`,
1016
1040
  `git -C ${root} config user.email "${provisionerBotEmail()}"`,
1017
1041
  ],
1018
1042
  add: `git -C ${root} add -A`,
@@ -1094,7 +1118,8 @@ async function refreshStandaloneCorePin(inst, deps) {
1094
1118
  // cwd, deploy-instance.sh §4); co-tenant/dedicated run this control-plane checkout's src/.
1095
1119
 
1096
1120
  module.exports = {
1097
- APP_USER,
1121
+ appGroupExpr,
1122
+ appUser,
1098
1123
  accountPlaneGap,
1099
1124
  accountPreflight,
1100
1125
  CORE_PKG_DIR,
@@ -11,7 +11,7 @@
11
11
  const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
  const { CONFIG, REPO_ROOT } = require('./provision-config.js');
14
- const { APP_USER, CORE_PKG_DIR, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
14
+ const { CORE_PKG_DIR, appUser, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
15
15
 
16
16
  function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
17
17
  const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
@@ -103,8 +103,8 @@ Requires=postgresql.service
103
103
 
104
104
  [Service]
105
105
  Type=oneshot
106
- User=${APP_USER}
107
- Group=${APP_USER}
106
+ User=${appUser()}
107
+ # No Group=: systemd then uses the app user's primary group, whatever it is named.
108
108
  # The DB to dump + where dumps land — set on the unit (not via the instance web.env)
109
109
  # so the dump targets the right database with no fallback to a stale default (root
110
110
  # cause of the task-2210 breakage). db-backup-nightly.sh reads both as env.
@@ -26,7 +26,7 @@
26
26
  // WOULD run and mutates nothing (no DB writes, no shell, no cloud call). The
27
27
  // LIVE leg is an explicit operator/timer `--apply`.
28
28
  // • PRIVILEGE model (task 2066, ADR 0128). The intent runner runs as a NON-root
29
- // user (User=lars) with passwordless sudo, which cannot itself write under /etc,
29
+ // user (the app user) with passwordless sudo, which cannot itself write under /etc,
30
30
  // reload systemd/Caddy, or peer-auth to Postgres as a superuser. PROVISION_SUDO=1
31
31
  // escalates exactly those steps (sudo install / sudo systemctl / sudo -u postgres);
32
32
  // default OFF = bare commands (root / test / co-tenant-legacy contexts unchanged).
@@ -50,7 +50,7 @@ const cp = require('node:child_process');
50
50
  const crypto = require('node:crypto');
51
51
 
52
52
  const { CONFIG, MANIFEST_UA, softFailResult, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
53
- const { APP_USER, accountPreflight, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
53
+ const { accountPreflight, appGroupExpr, appUser, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
54
54
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
55
  const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
56
  const { resolveProcessManager } = require('./provision-process.js');
@@ -282,6 +282,9 @@ async function provisionInstance(inst, deps) {
282
282
  // useradd would crash mid-provision instead of refusing.
283
283
  const accountGap = accountPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, privileged, probe: deps.accountPlaneGap });
284
284
  if (accountGap) return { ok: false, error: accountGap };
285
+ // App-user preflight (task 1002719): steps below name the app user, and appUser() refuses
286
+ // root or an unusable name. Refuse HERE, before the status flip, not midway through.
287
+ try { appUser(); } catch (e) { return { ok: false, error: e.message }; }
285
288
  // Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
286
289
  // can never certify this domain, so refuse before touching it. Injectable like the DNS check.
287
290
  const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
@@ -640,7 +643,7 @@ runcmd:
640
643
  - [ bash, -lc, "${dbCreateCmd(i)} || true" ]
641
644
  - [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
642
645
  - [ bash, -lc, "${seedFirstVersionCmd(i)}" ]
643
- ${roleLines} - [ bash, -lc, "install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}" ]
646
+ ${roleLines} - [ bash, -lc, "install -d -o ${appUser()} -g ${appGroupExpr()} -m 0750 ${CONFIG.backupDir}" ]
644
647
  - [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug} ${backupUnitName(i)}.timer" ]
645
648
  `;
646
649
  }
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.936'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.938'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -38,6 +38,9 @@ const P = require('../modules/provisioning/provisioning.js');
38
38
  const G = require('../modules/provisioning/control-plane-guard.js');
39
39
  const RP = require('../scripts/gds/provision-repo.js');
40
40
 
41
+ // task 1002719: the app user is derived, so pin one that is nobody's real account.
42
+ process.env.PROVISION_APP_USER = 'appsvc';
43
+
41
44
  let passed = 0;
42
45
  let failed = 0;
43
46
  const t = async (name, fn) => {
@@ -433,7 +436,7 @@ await t('the control plane upgrades as the operator account, never as root', asy
433
436
  const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
434
437
  await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
435
438
  const cmd = upgradeCall(d);
436
- assert.match(cmd, /sudo -u lars /, 'runs as the operator account');
439
+ assert.match(cmd, /sudo -u appsvc /, 'runs as the operator account');
437
440
  assert.doesNotMatch(cmd, /sudo node /, 'a bare `sudo node` is the bug — it lands on root');
438
441
  });
439
442
 
@@ -442,7 +445,7 @@ await t('a tenant upgrades as its OWN account, which is also its Postgres login
442
445
  await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
443
446
  const cmd = upgradeCall(d);
444
447
  assert.match(cmd, /sudo -u bongos-test4 /, 'the instance account, not the operator and not root');
445
- assert.doesNotMatch(cmd, /sudo -u lars /, 'one tenant must never be upgraded under another account');
448
+ assert.doesNotMatch(cmd, /sudo -u appsvc /, 'one tenant must never be upgraded under another account');
446
449
  });
447
450
 
448
451
  await t('the database is NAMED, not discovered — an adopted repo has no core at its root to ask', async () => {
@@ -691,7 +694,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
691
694
  // is reported rather than silently papered over.
692
695
  const d = acctDeps({ accountExists: false });
693
696
  const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
694
- assert.equal(r.runAs, 'lars');
697
+ assert.equal(r.runAs, 'appsvc');
695
698
  assert.equal(r.fellBack, true);
696
699
  assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
697
700
  });
@@ -699,7 +702,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
699
702
  await t('the control plane is never probed — it has no instance account by design', () => {
700
703
  const d = acctDeps({ accountExists: false });
701
704
  const r = R.resolveRunAs(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos' }), d);
702
- assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'lars', fellBack: false });
705
+ assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'appsvc', fellBack: false });
703
706
  assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 0, 'no probe was spent');
704
707
  });
705
708
 
@@ -707,7 +710,7 @@ await t('the dry run is invoked as the account that actually exists', async () =
707
710
  const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
708
711
  await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
709
712
  const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
710
- assert.match(cmd, /sudo -u lars /, `must not sudo to an account that does not exist — got: ${cmd}`);
713
+ assert.match(cmd, /sudo -u appsvc /, `must not sudo to an account that does not exist — got: ${cmd}`);
711
714
  assert.doesNotMatch(cmd, /bongos-test4/);
712
715
  });
713
716
 
@@ -715,7 +718,7 @@ await t('the MOVE probes identically, so preview and move cannot diverge on the
715
718
  const d = acctDeps({ accountExists: false });
716
719
  await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
717
720
  const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
718
- assert.match(cmd, /sudo -u lars /, 'the move follows the same probe as the preview');
721
+ assert.match(cmd, /sudo -u appsvc /, 'the move follows the same probe as the preview');
719
722
  });
720
723
 
721
724
  await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly widening it', () => {
@@ -789,7 +792,7 @@ await t('a ✖ line still wins over the raw tail', async () => {
789
792
  await t('a fallback is recorded on the verdict the owner reads', async () => {
790
793
  const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
791
794
  const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
792
- assert.equal(v.ran_as, 'lars');
795
+ assert.equal(v.ran_as, 'appsvc');
793
796
  assert.match(v.account_note, /no bongos-test4 account yet/);
794
797
  });
795
798
 
@@ -42,6 +42,7 @@ const NON_GRANT_MIGRATIONS = new Set([
42
42
  'government_007_amendments.sql', // R14 amendment proposals table (task 1003027)
43
43
  'government_008_closed_items_index.sql', // R16 closed-history index (task 1003029)
44
44
  'government_017_board_section_keys_v2.sql', // BV2.R06 board section_key CHECK widening (task 1004085)
45
+ 'government_018_passed_items_index.sql', // BV2.R17 passed-item lookup index (task 1004096)
45
46
  ]);
46
47
 
47
48
  // Permission keys RENAMED after the grant migrations that first granted them.