@bongos/core 1.19.733 → 1.19.735

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 (34) hide show
  1. package/.bongos-core.json +61 -36
  2. package/config/branding.neutral.json +3 -2
  3. package/docs/adr/0161-publish-on-merge.md +3 -0
  4. package/docs/adr/0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md +141 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/modules-contract.md +22 -0
  8. package/modules/agents/lib/spend-ceiling.js +170 -0
  9. package/modules/agents/migrations/agents_002_payer.sql +87 -0
  10. package/modules/agents/module.json +2 -1
  11. package/modules/agents/routes/agents.js +62 -1
  12. package/modules/agents/spawn.js +21 -3
  13. package/modules/provisioning/provisioning.js +17 -1
  14. package/modules/provisioning/tests/provisioning.mjs +8 -8
  15. package/modules/ui-design/kit/serve.js +1 -0
  16. package/package-lock.json +2 -2
  17. package/package.json +1 -1
  18. package/scripts/gds/bump-version.js +14 -5
  19. package/scripts/gds/exec-path-guard.js +3 -3
  20. package/scripts/gds/package-core.js +31 -10
  21. package/src/branding.js +13 -0
  22. package/src/module-api.js +1 -1
  23. package/src/module-loader/loader.js +6 -0
  24. package/src/module-loader/manifest-schema.js +30 -0
  25. package/src/modules.js +107 -2
  26. package/tests/agents_spend_guard.mjs +272 -0
  27. package/tests/agents_write_routes.mjs +51 -1
  28. package/tests/bump_version.mjs +78 -1
  29. package/tests/currency_label.mjs +9 -4
  30. package/tests/leak_scan_fail_closed.mjs +108 -0
  31. package/tests/provision_settings_apply.mjs +11 -5
  32. package/tests/provisioning_settings.mjs +11 -7
  33. package/tests/provisioning_settings_apply.mjs +4 -4
  34. package/tests/provisioning_settings_env.mjs +5 -1
@@ -59,8 +59,11 @@
59
59
  // (task 1002218 / audit H3) — deterministic + network-free.
60
60
  // --self-verify-boot ALSO `npm install <tgz>` into a throwaway consumer + boot /healthz
61
61
  // (reuses tests/consumer_layout_boot.mjs; needs npm/registry access).
62
- // --leak-scan-strict make the post-build leak scan FATAL on any prior-instance literal
63
- // (default: the scan runs + WARNS; --no-leak-scan skips it). task 1002220 / H6.
62
+ // --no-leak-scan THE ONLY ESCAPE from the post-build leak scan: skips it entirely.
63
+ // The scan is FAIL-CLOSED by default an un-allowlisted prior-instance
64
+ // literal in the built artifact aborts the build (task 1003870 / H6).
65
+ // --leak-scan-strict accepted as an explicit no-op; fatal IS the default since task
66
+ // 1003870. Kept so CI can state the intent it relies on out loud.
64
67
  // Outputs (nothing is committed — dist/ is gitignored):
65
68
  // dist/bongos-core-<version>/ the redacted core tree (inspectable)
66
69
  // dist/bongos-core-<version>.tgz the installable unit
@@ -629,6 +632,18 @@ function scanKindOf(pathRel) {
629
632
  return 'functional';
630
633
  }
631
634
 
635
+ // The post-build leak scan's POLICY, split out of main() so a test can plant a literal and prove
636
+ // the build refuses without running a full 2-minute package (task 1003870). Pure: argv in, verdict
637
+ // out. Fail-closed — any un-allowlisted prior-instance literal is fatal unless the operator has
638
+ // explicitly said --no-leak-scan, which skips the scan altogether rather than downgrading it.
639
+ // `--leak-scan-strict` is honoured as a no-op: it named this behaviour before it became default.
640
+ function leakScanVerdict(remainingLiteralHits, argv = process.argv) {
641
+ if (argv.includes('--no-leak-scan')) return { run: false, fatal: false, reason: 'skipped' };
642
+ const n = (remainingLiteralHits || []).length;
643
+ if (n === 0) return { run: true, fatal: false, reason: 'clean' };
644
+ return { run: true, fatal: true, reason: 'prior-instance-literals' };
645
+ }
646
+
632
647
  // Independent post-build leak scan over the built core file set (task 1002220 / audit H6). Two layers:
633
648
  // • INSTANCE LITERALS — findLeaks() over PRIOR_INSTANCE_LITERALS (+ the public-IP floor) across
634
649
  // EVERY shipped file: brand/owner/history that must not ship in a portable core. This is the
@@ -942,14 +957,14 @@ function main() {
942
957
 
943
958
  // Post-build leak scan (task 1002220 / audit H6): independent verification that the BUILT
944
959
  // artifact carries no prior-instance identity, + a warn-level secret-shape scan over functional
945
- // files. Runs on every build (skip with --no-leak-scan). Prior-instance literals are a WARNING
946
- // by default and a hard FAILURE under --leak-scan-strict (the trunk still carries residual
947
- // history until task 1002219 sweeps it + task 1002217 adds the fail-closed floor gate). A
948
- // secret-shape hit is ALWAYS a non-fatal warning — the R88 functional-verbatim fix is preserved.
960
+ // files. Runs on every build and is FAIL-CLOSED: an un-allowlisted prior-instance literal aborts
961
+ // the build. The trunk sweep (task 1003869) cleared the residue those literals used to represent,
962
+ // so warning instead of failing would only let it creep back (task 1003870). --no-leak-scan is
963
+ // the sole escape. A secret-shape hit stays a non-fatal warning — those 14 files were each
964
+ // confirmed a test fixture (task 1003871) and the R88 functional-verbatim fix is preserved.
949
965
  if (!process.argv.includes('--no-leak-scan')) {
950
966
  const scanFiles = staged.map((s) => ({ path: s.name.startsWith(TAR_ROOT) ? s.name.slice(TAR_ROOT.length) : s.name, content: s.buf }));
951
967
  const scan = scanArtifactForLeaks(scanFiles);
952
- const strict = process.argv.includes('--leak-scan-strict');
953
968
  // Named, reasoned exceptions (task 1003869). A detector must carry the pattern it
954
969
  // detects and a "public IPs are flagged" test must be handed a public IP, so those
955
970
  // hits are real but not leaks. Each is listed per (file, literal) with its reason in
@@ -966,17 +981,21 @@ function main() {
966
981
  for (const e of stale) console.error(` ${e.path} [${e.literal}]`);
967
982
  }
968
983
  scan.literalHits = remaining;
984
+ const verdict = leakScanVerdict(scan.literalHits);
969
985
  if (scan.literalHits.length) {
970
986
  const byLit = {};
971
987
  for (const h of scan.literalHits) byLit[h.sample] = (byLit[h.sample] || 0) + 1;
972
988
  const summary = Object.entries(byLit).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k}×${n}`).join(', ');
973
989
  const files = [...new Set(scan.literalHits.map((h) => h.path))];
974
990
  console.error('');
975
- console.error(`[leak-scan] ${strict ? 'FAILED' : 'WARNING'} — ${scan.literalHits.length} prior-instance literal hit(s) across ${files.length} shipped file(s): ${summary}`);
991
+ console.error(`[leak-scan] FAILED — ${scan.literalHits.length} prior-instance literal hit(s) across ${files.length} shipped file(s): ${summary}`);
976
992
  for (const p of files.slice(0, 30)) console.error(` ${p}`);
977
993
  if (files.length > 30) console.error(` … +${files.length - 30} more file(s)`);
978
- if (strict) { console.error('[leak-scan] --leak-scan-strict: refusing to certify a leaky artifact.'); process.exit(1); }
979
- console.error('[leak-scan] non-fatal for nowthe trunk sweep (task 1003869) is clearing the residue; it becomes a hard gate when task 1003870 arms publish.yml. Run --leak-scan-strict to enforce today.');
994
+ console.error('[leak-scan] refusing to certify a leaky artifact. Either remove the literal at SOURCE (check what');
995
+ console.error('[leak-scan] REGENERATES the filea generator re-leaks on every run, task 1003911), or, if the hit is');
996
+ console.error('[leak-scan] a detector carrying its own pattern, add a named (path, class) reason to');
997
+ console.error('[leak-scan] scripts/gds/leak-scan-allowlist.js. --no-leak-scan skips this gate entirely.');
998
+ if (verdict.fatal) process.exit(1);
980
999
  } else {
981
1000
  console.error('[leak-scan] ✓ no prior-instance literals in the built artifact.');
982
1001
  }
@@ -1021,6 +1040,8 @@ module.exports = {
1021
1040
  selfVerify, reproducePinFromTree,
1022
1041
  // task 1002220 (audit H6) — post-build leak scan (prior-instance literals + functional secret shapes)
1023
1042
  scanArtifactForLeaks, PRIOR_INSTANCE_LITERALS,
1043
+ // task 1003870 — the fail-closed verdict the build acts on (pure, so a test can plant a literal)
1044
+ leakScanVerdict,
1024
1045
  // task 1002217 (audit H4) — the fixed neutral floor injected into the doc-redaction + fail-closed gate
1025
1046
  PRIOR_INSTANCE_FLOOR,
1026
1047
  };
package/src/branding.js CHANGED
@@ -77,6 +77,12 @@ const ENV_OVERRIDES = [
77
77
  // that has been strict since it stood up.
78
78
  ['ARTIST_GATE', ['project', 'artistGate']],
79
79
  ['ARTIST_GATE_SINCE', ['project', 'artistGateSince']],
80
+ // Who pays when a module spends money for whoever called it (task 1003884) —
81
+ // none | project | builder. src/modules.js reads it through this pack at config
82
+ // load and REFUSES to enable a module that declares metered spend while the join
83
+ // door is `open` and this is `none`. Like its neighbours it is memoized for the
84
+ // process, which is why the platform restarts the instance to apply a change.
85
+ ['SPEND_PAYER', ['project', 'spendPayer']],
80
86
  ];
81
87
 
82
88
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
@@ -265,6 +271,13 @@ function clientBranding(b = branding()) {
265
271
  // DEFAULT, which for this one key is 'strict' rather than today's behaviour
266
272
  // — the owner's ruling, ADR 0241 §4.
267
273
  artistGate: b.project?.artistGate || 'strict',
274
+ // Who pays for metered module spend (task 1003884). Published for the same
275
+ // read-back reason as its neighbours — and for one of its own: this is the
276
+ // key that can REFUSE a module, so an owner looking at a module that will
277
+ // not turn on needs the instance's own answer to "what did it restart
278
+ // with?", not the platform's record of what they saved. The fallback is the
279
+ // platform default, which for this key IS today's behaviour.
280
+ spendPayer: b.project?.spendPayer || 'none',
268
281
  },
269
282
  };
270
283
  }
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.733'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.735'; // 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');
@@ -128,6 +128,12 @@ function discoveredRegistry(opts) {
128
128
  _dir: dir,
129
129
  _provides: manifest.provides || [],
130
130
  _consumes: manifest.consumes || [],
131
+ // The module's own declaration that it METERS money on a caller's behalf
132
+ // (task 1003884). Projected here because src/modules.js refuses to enable
133
+ // such a module where anyone can sign up and nobody is on the hook — and a
134
+ // kernel file may not name a module key (fitness Check 10), so the
135
+ // declaration has to travel WITH the manifest rather than be looked up.
136
+ _spend: manifest.spend || {},
131
137
  };
132
138
  }
133
139
  return out;
@@ -39,6 +39,12 @@
39
39
  // // the APEX ROOT — never both (ADR 0218).
40
40
  // "migrations": true // true => modules/<key>/migrations/ exists (or an array of names)
41
41
  // },
42
+ // "spend": { // OPTIONAL. This module METERS money on a caller's behalf
43
+ // "requiresPayer": true // (task 1003884) — its surfaces can spend real money for
44
+ // }, // whoever calls them, so the core must know WHO PAYS before
45
+ // // it may be enabled where anyone can sign up. src/modules.js
46
+ // // refuses the enablement when the join door is `open` and no
47
+ // // payer is configured.
42
48
  // "provides": ["reward"], // OPTIONAL. seam PORTS this module registers a provider for.
43
49
  // "consumes": ["grade"], // OPTIONAL. seam ports this module resolves (required capabilities).
44
50
  // "prerequisites": { "modules": ["economy"] }, // OPTIONAL. other modules that must be enabled.
@@ -77,6 +83,16 @@ const ATTRIBUTION_KEYS = ['author', 'origin', 'license', 'maintainer'];
77
83
  // optionally a `removeAfter` core version.
78
84
  // orphaned nobody owns it. Still needs a `note`/`successor` so the
79
85
  // next reader knows the situation, not just the label.
86
+ // The `spend` block (task 1003884). A module declaring `requiresPayer: true` is
87
+ // saying its surfaces can spend real money on behalf of whoever calls them — so
88
+ // the core refuses to ENABLE it on an instance whose join door is `open` with no
89
+ // configured payer (src/modules.js). Closed key set for the same reason
90
+ // `contributes` and `maintenance` have one: a typo'd "requirespayer" would
91
+ // silently declare NO spend, and that is the direction that costs money.
92
+ // Deliberately NOT exported: the validator and its own error message are the only
93
+ // readers, and an export nothing imports is dead weight the knip ratchet counts.
94
+ const SPEND_KEYS = ['requiresPayer'];
95
+
80
96
  const MAINTENANCE_STATUSES = ['core-maintained', 'maintained', 'deprecated', 'orphaned'];
81
97
  const MAINTENANCE_KEYS = ['status', 'since', 'removeAfter', 'successor', 'note'];
82
98
  // Statuses that describe a module on its way out — these owe a deprecation path.
@@ -190,6 +206,20 @@ function validateManifest(obj) {
190
206
  }
191
207
  }
192
208
 
209
+ // --- optional spend declaration (task 1003884) ---
210
+ if ('spend' in obj) {
211
+ const sp = obj.spend;
212
+ if (!isObj(sp)) E('spend: must be an object (e.g. { "requiresPayer": true })');
213
+ else {
214
+ for (const k of Object.keys(sp)) {
215
+ if (!SPEND_KEYS.includes(k)) E(`spend.${k}: unknown key (allowed: ${SPEND_KEYS.join(', ')})`);
216
+ }
217
+ if ('requiresPayer' in sp && typeof sp.requiresPayer !== 'boolean') {
218
+ E('spend.requiresPayer: must be a boolean when present');
219
+ }
220
+ }
221
+ }
222
+
193
223
  // --- optional seam declarations ---
194
224
  if ('provides' in obj && !isStrArray(obj.provides)) E('provides: must be an array of non-empty strings (port names)');
195
225
  if ('consumes' in obj && !isStrArray(obj.consumes)) E('consumes: must be an array of non-empty strings (port names)');
package/src/modules.js CHANGED
@@ -118,10 +118,75 @@ function coerceBool(v) {
118
118
  return undefined;
119
119
  }
120
120
 
121
+ // WHO PAYS for metered module spend on this instance (task 1003884). Two
122
+ // answers are a payer and the absence of one is not: `project` puts the bill on
123
+ // whoever runs the instance, `builder` on the caller who spent it. Anything else
124
+ // — unset, empty, a typo, a value from a newer core — is NO payer, which is the
125
+ // reading that costs nobody money by accident.
126
+ const PAYERS = Object.freeze(['project', 'builder']);
127
+
128
+ // Does this registry entry declare that its surfaces spend real money on behalf
129
+ // of whoever calls them? The declaration lives in the module's own module.json
130
+ // (`"spend": { "requiresPayer": true }`) and rides through the loader as `_spend`,
131
+ // because fitness Check 10 forbids the kernel from naming a module key — the
132
+ // guard below has to be true of any module that says this about itself, not of
133
+ // one the resolver has heard of.
134
+ const metersSpend = (entry) => !!(entry && entry._spend && entry._spend.requiresPayer === true);
135
+
136
+ // The spend guard's own posture. `joinDoorOpen` is "any GitHub account becomes a
137
+ // builder seconds after signing up" — the composed join door, not the
138
+ // `joinability` knob alone (src/bongos/project-door.js).
139
+ const NO_SPEND_RISK = Object.freeze({ joinDoorOpen: false, payer: null });
140
+
141
+ // WHICH enabled modules must be REFUSED, and why this is a config-load decision.
142
+ //
143
+ // A module that meters spend for its callers is safe in exactly two situations:
144
+ // the people who can call it were let in deliberately (the door is not open), or
145
+ // somebody is on the hook for the bill (a payer is configured, and the module's
146
+ // own per-payer ceiling bounds it). With an OPEN door and NO payer, an account
147
+ // created seconds ago can spend the instance's money with nobody to bill and no
148
+ // cap to stop it — so the enablement itself is refused.
149
+ //
150
+ // AT CONFIG LOAD, NOT AT FIRST CALL. A per-request check would let the module
151
+ // mount, advertise its routes in the manifest, run its migrations and appear
152
+ // enabled everywhere the UI reads `modules.enabled` — and then refuse one caller
153
+ // at a time, which is a broken feature rather than a closed door. Refusing the
154
+ // enablement means the surface never exists.
155
+ //
156
+ // PURE, and exported for direct unit testing: the fail-closed reading of an
157
+ // UNKNOWN posture belongs to loadModules, which is where "unknown" can arise.
158
+ function spendRefusals(resolved, registry, { joinDoorOpen = false, payer = null } = {}) {
159
+ if (!joinDoorOpen) return [];
160
+ if (PAYERS.includes(payer)) return [];
161
+ return Object.keys(resolved).filter((k) => resolved[k] === true && metersSpend(registry[k]));
162
+ }
163
+
164
+ // The refusal is LOUD. A module silently resolving to off is how an operator
165
+ // spends an afternoon wondering why their routes 404 — and this one is refusing
166
+ // something they explicitly asked for, so it has to say what to do about it.
167
+ // `log` is injectable for the same reason instance-config's legacy-env warning
168
+ // injects it: a test asserts the warning fired, not only that the value resolved.
169
+ function warnSpendRefusal(key, envPrefix, log = console) {
170
+ log.warn(
171
+ `[bongos] module "${key}" is enabled in config but was REFUSED: it spends money on behalf of `
172
+ + `whoever calls it, this project's join door is \`open\` (anyone may sign up), and no payer is `
173
+ + `configured — so a stranger's spend would have nobody to bill. Set ${envPrefix}_SPEND_PAYER to `
174
+ + `${PAYERS.join(' or ')} (the platform pushes it from the project's \`spend_payer\` setting), or `
175
+ + `close the join door. The module stays OFF until then.`,
176
+ );
177
+ }
178
+
121
179
  // Pure resolver — all inputs injected, so tests drive it with zero I/O.
122
180
  // Returns a frozen { <moduleKey>: boolean } covering EVERY registry key, plus a
123
181
  // non-enumerable resolution that throws on an unknown module key (typo guard).
124
- function resolveModules({ neutral = {}, instance = {}, env = {}, envPrefix = FALLBACK_ENV_PREFIX, registry = MODULE_REGISTRY } = {}) {
182
+ function resolveModules({
183
+ neutral = {}, instance = {}, env = {}, envPrefix = FALLBACK_ENV_PREFIX, registry = MODULE_REGISTRY,
184
+ // The spend guard's inputs (task 1003884). Defaulted to "no risk" so every
185
+ // caller that does not know about spend resolves exactly as before; loadModules
186
+ // is the one that reads the real posture, and the one that fails closed when it
187
+ // cannot.
188
+ spendGuard = NO_SPEND_RISK, warn = warnSpendRefusal,
189
+ } = {}) {
125
190
  // The known-key set + defaults come from the passed registry: callers inject
126
191
  // the built-in MODULE_REGISTRY (the default, what the pure unit tests use) or
127
192
  // the effective built-in+discovered registry (what loadModules passes). This
@@ -175,6 +240,14 @@ function resolveModules({ neutral = {}, instance = {}, env = {}, envPrefix = FAL
175
240
  const v = key in merged ? coerceBool(merged[key]) : undefined;
176
241
  resolved[key] = v === undefined ? registry[key].default : v;
177
242
  }
243
+ // THE SPEND GUARD, applied LAST — after config, after env, after the default.
244
+ // It is deliberately not another precedence level: a refusal that env could
245
+ // outrank would be a suggestion, and the whole point is that the one knob an
246
+ // untrusted-but-cheap deploy would reach for cannot turn it back on.
247
+ for (const key of spendRefusals(resolved, registry, spendGuard)) {
248
+ resolved[key] = false;
249
+ warn(key, envPrefix);
250
+ }
178
251
  return Object.freeze(resolved);
179
252
  }
180
253
 
@@ -220,9 +293,37 @@ function loadModules({ neutralPath = NEUTRAL_PATH, instancePath, env = process.e
220
293
  // Reuse the branding env prefix so an instance has ONE prefix, not two.
221
294
  let envPrefix = FALLBACK_ENV_PREFIX;
222
295
  try { envPrefix = require('./branding').branding().envPrefix || envPrefix; } catch { /* fail-to-default */ }
296
+ // THE SPEND POSTURE, read once per load (task 1003884): is this project's
297
+ // composed join door `open`, and has the owner said who pays?
298
+ //
299
+ // `effectiveJoinDoor() === 'open'` is LITERALLY the expression openEnrollmentEnabled
300
+ // is defined as (src/bongos/auth-admission.js), read from the composer rather than
301
+ // through the gate so this file does not pull in the whole sign-in stack. That
302
+ // identity is the point: the guard and the gate that actually admits strangers
303
+ // cannot answer one stranger differently, which is the same argument task 1003579
304
+ // made when it moved the gate onto the composed door.
305
+ //
306
+ // WHAT THE CATCH COVERS, stated precisely rather than generously. A pack that
307
+ // fails to PARSE never reaches here — effectiveJoinDoor swallows that itself and
308
+ // resolves the default door, `apply`, which is closed. That is not a fail-open
309
+ // hole: the real admission gate reads the same broken pack and refuses the same
310
+ // self-enrolment, so a door this guard reads as closed IS closed. The catch is
311
+ // for the harder failure — the require itself throwing, in tooling that has no
312
+ // such file — and THERE the default is the dangerous reading rather than the
313
+ // convenient one, because the thing being guarded is money leaving the account.
314
+ // The blast radius of being wrong is one module resolving OFF, and since every
315
+ // module defaults to off, an instance that never enabled one notices nothing.
316
+ let spendGuard = { joinDoorOpen: true, payer: null };
317
+ try {
318
+ // Both are KERNEL files (fitness Check 12), required lazily for the same
319
+ // reason branding is just above: require-time must never crash tooling.
320
+ const { effectiveJoinDoor } = require('./bongos/project-door'); // eslint-disable-line global-require
321
+ const project = require('./branding').branding().project || {}; // eslint-disable-line global-require
322
+ spendGuard = { joinDoorOpen: effectiveJoinDoor() === 'open', payer: project.spendPayer || null };
323
+ } catch { /* unreadable posture — the fail-closed default above stands */ }
223
324
  // Resolve against the EFFECTIVE registry (built-in + discovered) so a config
224
325
  // flag for a discovered module is honored. No-op until a module exists.
225
- return resolveModules({ neutral, instance, env, envPrefix, registry: effectiveRegistry() });
326
+ return resolveModules({ neutral, instance, env, envPrefix, registry: effectiveRegistry(), spendGuard });
226
327
  }
227
328
 
228
329
  // Resolved singleton for app code; lazy so require-time never crashes tooling.
@@ -282,6 +383,10 @@ module.exports = {
282
383
  effectiveRegistry,
283
384
  knownModules,
284
385
  CORE_DISCIPLINES,
386
+ PAYERS,
387
+ metersSpend,
388
+ spendRefusals,
389
+ warnSpendRefusal,
285
390
  resolveModules,
286
391
  loadModules,
287
392
  modules,
@@ -0,0 +1,272 @@
1
+ // tests/agents_spend_guard.mjs — the spend guard and the per-payer spend ceiling
2
+ // (task 1003884).
3
+ //
4
+ // Two independent walls, tested apart because they fail in different directions:
5
+ //
6
+ // 1. the CONFIG-LOAD guard (src/modules.js) — a module that declares it meters
7
+ // money for its callers is not ENABLED at all on an instance whose join door
8
+ // is `open` with no payer configured;
9
+ // 2. the per-payer MONTHLY CEILING (modules/agents/lib/spend-ceiling.js) — a
10
+ // payer who has spent their month cannot fire, and so, deliberately, cannot
11
+ // anyone whose ceiling could not be established.
12
+ //
13
+ // The second is where task 1001393's defect is pinned: that spend brake read
14
+ // month-to-date spend from a database it could not reach and FAILED OPEN, so it
15
+ // never tripped. Several cases below exist only to assert this one fails closed.
16
+ import assert from 'node:assert/strict';
17
+ import { test } from 'node:test';
18
+ import { readFileSync } from 'node:fs';
19
+ // Imported BY NAME rather than destructured off a default import: knip cannot
20
+ // follow a name through `import mod from '...'` + `const { x } = mod`, so the
21
+ // guard's tested exports would read as dead code and the ratchet would block the
22
+ // merge on a file whose every export is genuinely used right here.
23
+ import { resolveModules, spendRefusals, metersSpend, PAYERS, warnSpendRefusal } from '../src/modules.js';
24
+ import { validateManifest } from '../src/module-loader/manifest-schema.js';
25
+ import ceilingMod from '../modules/agents/lib/spend-ceiling.js';
26
+ import spawnMod from '../modules/agents/spawn.js';
27
+
28
+ const { createSpendCeiling, payerFor, monthStartUtc, DEFAULT_CAP_USD } = ceilingMod;
29
+ const { plannedRow } = spawnMod;
30
+
31
+ // A registry with one metered module and one ordinary one, so every assertion can
32
+ // show that the guard bites the declaring module and NOTHING else.
33
+ const REG = {
34
+ metered: { default: false, contributes: {}, _spend: { requiresPayer: true } },
35
+ plain: { default: false, contributes: {} },
36
+ };
37
+ const BOTH_ON = { modules: { metered: true, plain: true } };
38
+ const silent = () => {};
39
+ const resolve = (spendGuard, extra = {}) =>
40
+ resolveModules({ instance: BOTH_ON, registry: REG, spendGuard, warn: silent, ...extra });
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // 1. the config-load guard
44
+ // ---------------------------------------------------------------------------
45
+
46
+ test('an OPEN join door with NO payer refuses the metered module — and only that one', () => {
47
+ const m = resolve({ joinDoorOpen: true, payer: null });
48
+ assert.equal(m.metered, false, 'anyone can sign up and nobody is on the hook — it must not mount');
49
+ assert.equal(m.plain, true, 'a module that never said it spends money is untouched');
50
+ });
51
+
52
+ test('a CLOSED join door leaves it enabled: the people who can call it were let in deliberately', () => {
53
+ assert.equal(resolve({ joinDoorOpen: false, payer: null }).metered, true);
54
+ });
55
+
56
+ test('an open door WITH a payer leaves it enabled — somebody is on the hook, and the ceiling bounds them', () => {
57
+ for (const payer of PAYERS) {
58
+ assert.equal(resolve({ joinDoorOpen: true, payer }).metered, true, payer);
59
+ }
60
+ });
61
+
62
+ test('anything that is not one of the two payer words is NO payer', () => {
63
+ // The direction that matters: a typo, a stale value, or a word a newer core
64
+ // understands must never read as "somebody is paying".
65
+ for (const bad of ['Project', 'BUILDER', 'none', '', ' project', 'owner', null, undefined, 0, true]) {
66
+ assert.equal(resolve({ joinDoorOpen: true, payer: bad }).metered, false, JSON.stringify(bad));
67
+ }
68
+ });
69
+
70
+ test('the refusal outranks the env override — it is a wall, not another precedence level', () => {
71
+ // <PREFIX>_MODULE_METERED=1 is the highest-precedence way to turn a module on,
72
+ // and it is exactly the knob a cheap deploy reaches for. It must not lift this.
73
+ const m = resolveModules({
74
+ instance: { modules: { metered: false } },
75
+ env: { BONGOS_MODULE_METERED: '1' },
76
+ envPrefix: 'BONGOS',
77
+ registry: REG,
78
+ spendGuard: { joinDoorOpen: true, payer: null },
79
+ warn: silent,
80
+ });
81
+ assert.equal(m.metered, false, 'env cannot buy its way past the guard');
82
+ });
83
+
84
+ test('the refusal is LOUD: it names the module, the two payer words and the env var that lifts it', () => {
85
+ const said = [];
86
+ resolveModules({
87
+ instance: BOTH_ON, registry: REG, envPrefix: 'MERCURY',
88
+ spendGuard: { joinDoorOpen: true, payer: null },
89
+ warn: (key, prefix) => said.push([key, prefix]),
90
+ });
91
+ assert.deepEqual(said, [['metered', 'MERCURY']], 'exactly the refused module, under this pack\'s prefix');
92
+
93
+ // And the default warner writes something an operator can act on, under the
94
+ // instance's OWN prefix rather than a hardcoded one.
95
+ const lines = [];
96
+ warnSpendRefusal('metered', 'MERCURY', { warn: (s) => lines.push(s) });
97
+ assert.equal(lines.length, 1);
98
+ assert.match(lines[0], /metered/);
99
+ assert.match(lines[0], /MERCURY_SPEND_PAYER/, 'names the variable, under the right prefix');
100
+ for (const p of PAYERS) assert.match(lines[0], new RegExp(p), `names the ${p} payer`);
101
+ });
102
+
103
+ test('callers that know nothing about spend resolve exactly as before', () => {
104
+ // Every existing call site passes no spendGuard. The default must change nothing
105
+ // — the fail-closed reading belongs to loadModules, where "unknown" can arise.
106
+ assert.equal(resolveModules({ instance: BOTH_ON, registry: REG }).metered, true);
107
+ });
108
+
109
+ test('metersSpend reads the module\'s own declaration, and only a literal true', () => {
110
+ assert.equal(metersSpend({ _spend: { requiresPayer: true } }), true);
111
+ for (const bad of [undefined, null, {}, { _spend: {} }, { _spend: { requiresPayer: 'true' } }, { _spend: { requiresPayer: 1 } }]) {
112
+ assert.equal(metersSpend(bad), false, JSON.stringify(bad));
113
+ }
114
+ });
115
+
116
+ test('spendRefusals never names a module that was already off', () => {
117
+ const off = { metered: false, plain: false };
118
+ assert.deepEqual(spendRefusals(off, REG, { joinDoorOpen: true, payer: null }), []);
119
+ });
120
+
121
+ test('the manifest declaration is a CLOSED key set — a typo must not read as "no spend"', () => {
122
+ const base = { key: 'x', title: 't', description: 'd', coreVersion: '^1.0.0', contributes: {} };
123
+ assert.equal(validateManifest({ ...base, spend: { requiresPayer: true } }).valid, true);
124
+ assert.equal(validateManifest({ ...base, spend: { requirespayer: true } }).valid, false,
125
+ 'a misspelled key silently declaring NO spend is the direction that costs money');
126
+ assert.equal(validateManifest({ ...base, spend: { requiresPayer: 'yes' } }).valid, false);
127
+ assert.equal(validateManifest({ ...base, spend: true }).valid, false);
128
+ });
129
+
130
+ test('the agents module actually declares it — the guard guards something', () => {
131
+ // The whole chain is inert if the manifest never says so, and that failure is
132
+ // invisible: everything still resolves, nothing is ever refused.
133
+ const manifest = JSON.parse(readFileSync(new URL('../modules/agents/module.json', import.meta.url), 'utf8'));
134
+ assert.equal(manifest.spend && manifest.spend.requiresPayer, true);
135
+ assert.equal(manifest.default, false, 'and it still ships disabled');
136
+ });
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // 2. who pays for one fire
140
+ // ---------------------------------------------------------------------------
141
+
142
+ test('payerFor: `builder` bills the caller, everything else bills the project', () => {
143
+ assert.deepEqual(payerFor('builder', 7), { payer_kind: 'builder', payer_builder_id: 7 });
144
+ assert.deepEqual(payerFor('project', 7), { payer_kind: 'project', payer_builder_id: null });
145
+ assert.deepEqual(payerFor('none', 7), { payer_kind: 'project', payer_builder_id: null });
146
+ assert.deepEqual(payerFor(null, 7), { payer_kind: 'project', payer_builder_id: null });
147
+ });
148
+
149
+ test('an event fire has no caller, so it can only be the project — never nobody', () => {
150
+ // Nobody asked for it, so there is nobody to bill; falling to "no payer" would
151
+ // make the event path the one unbounded route through the ceiling.
152
+ assert.deepEqual(payerFor('builder', null), { payer_kind: 'project', payer_builder_id: null });
153
+ assert.deepEqual(payerFor('builder', undefined), { payer_kind: 'project', payer_builder_id: null });
154
+ });
155
+
156
+ test('every ledger row carries a payer — including a no-go, which cost nothing', () => {
157
+ const go = plannedRow({ definition: { id: 1, name: 'a' }, gate: { decision: 'go' }, requestedByBuilderId: 4, spendPayer: 'builder' });
158
+ assert.equal(go.payer_kind, 'builder');
159
+ assert.equal(go.payer_builder_id, 4);
160
+
161
+ const nogo = plannedRow({ definition: { id: 1, name: 'a' }, gate: { decision: 'no-go', reason: 'x' }, requestedByBuilderId: 4, spendPayer: 'builder' });
162
+ assert.equal(nogo.status, 'skipped');
163
+ assert.equal(nogo.payer_kind, 'builder', 'a refusal is still a fire somebody would have been billed for');
164
+ });
165
+
166
+ test('who ASKED and who PAYS are different columns, and differ under a project payer', () => {
167
+ const row = plannedRow({ definition: { id: 1, name: 'a' }, gate: { decision: 'go' }, requestedByBuilderId: 9, spendPayer: 'project' });
168
+ assert.equal(row.requested_by_builder_id, 9);
169
+ assert.equal(row.payer_kind, 'project');
170
+ assert.equal(row.payer_builder_id, null, 'the project pays; the asker is recorded but not billed');
171
+ });
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // 3. the per-payer monthly ceiling — every branch fails CLOSED
175
+ // ---------------------------------------------------------------------------
176
+
177
+ const poolOf = (spent) => ({ query: async () => ({ rows: [{ spent }] }) });
178
+ const AT = Date.UTC(2026, 8, 13, 12, 0, 0); // 2026-09-13
179
+ const PROJECT = { payer_kind: 'project', payer_builder_id: null };
180
+
181
+ test('under the cap: allowed, with the arithmetic shown', () => {
182
+ const c = createSpendCeiling({ capUsd: 25, now: () => AT });
183
+ return c.check(poolOf('4.5000'), PROJECT).then((r) => {
184
+ assert.equal(r.ok, true);
185
+ assert.equal(r.spentUsd, 4.5);
186
+ assert.equal(r.remainingUsd, 20.5);
187
+ assert.equal(r.reason, null);
188
+ });
189
+ });
190
+
191
+ test('at or past the cap: refused, and the refusal says which', async () => {
192
+ const c = createSpendCeiling({ capUsd: 25, now: () => AT });
193
+ for (const spent of ['25', '25.0001', '900']) {
194
+ const r = await c.check(poolOf(spent), PROJECT);
195
+ assert.equal(r.ok, false, spent);
196
+ assert.equal(r.reason, 'spend_cap_reached', spent);
197
+ assert.equal(r.remainingUsd, 0, spent);
198
+ }
199
+ });
200
+
201
+ test('AN UNREADABLE LEDGER REFUSES — the exact defect task 1001393 was filed about', async () => {
202
+ // That brake read month-to-date spend from a DB it could not reach and fell
203
+ // through to "go ahead". "I cannot tell what has been spent" is the moment a
204
+ // ceiling matters most.
205
+ const throws = { query: async () => { throw new Error('ECONNREFUSED'); } };
206
+ const r = await createSpendCeiling({ capUsd: 25, now: () => AT }).check(throws, PROJECT);
207
+ assert.equal(r.ok, false);
208
+ assert.equal(r.reason, 'ledger_unreadable');
209
+ assert.equal(r.spentUsd, null, 'it must not claim a sum it does not have');
210
+ });
211
+
212
+ test('a missing pool, and a sum that is not a number, are the same "I do not know"', async () => {
213
+ const c = createSpendCeiling({ capUsd: 25, now: () => AT });
214
+ assert.equal((await c.check(null, PROJECT)).reason, 'ledger_unreadable');
215
+ assert.equal((await c.check({}, PROJECT)).reason, 'ledger_unreadable');
216
+ for (const junk of ['abc', null, undefined, -1]) {
217
+ assert.equal((await c.check(poolOf(junk), PROJECT)).ok, false, JSON.stringify(junk));
218
+ }
219
+ });
220
+
221
+ test('NO CAP IS NOT AN INFINITE CAP — an unbounded ceiling is the defect wearing a cap\'s name', async () => {
222
+ for (const cap of [0, -5, Number.NaN, Number.POSITIVE_INFINITY, null, 'lots']) {
223
+ const r = await createSpendCeiling({ capUsd: cap, now: () => AT }).check(poolOf('0'), PROJECT);
224
+ assert.equal(r.ok, false, String(cap));
225
+ assert.equal(r.reason, 'no_spend_cap_configured', String(cap));
226
+ }
227
+ });
228
+
229
+ test('no payer to bill: refused, matching fire-budget\'s reading of an unidentified caller', async () => {
230
+ const c = createSpendCeiling({ capUsd: 25, now: () => AT });
231
+ for (const p of [null, undefined, {}, { payer_kind: 'nobody' }, { payer_kind: 'builder', payer_builder_id: null }]) {
232
+ const r = await c.check(poolOf('0'), p);
233
+ assert.equal(r.ok, false, JSON.stringify(p));
234
+ assert.equal(r.reason, 'no_payer', JSON.stringify(p));
235
+ }
236
+ });
237
+
238
+ test('the window is the calendar month, and the sum is scoped to THIS payer', async () => {
239
+ let captured = null;
240
+ const spy = { query: async (_sql, params) => { captured = params; return { rows: [{ spent: '1' }] }; } };
241
+ await createSpendCeiling({ capUsd: 25, now: () => AT })
242
+ .check(spy, { payer_kind: 'builder', payer_builder_id: 12 });
243
+ assert.deepEqual(captured[0], new Date(Date.UTC(2026, 8, 1)), 'since the 1st, UTC');
244
+ assert.equal(captured[1], 'builder');
245
+ assert.equal(captured[2], 12, 'one builder\'s month, not every builder\'s');
246
+ });
247
+
248
+ test('monthStartUtc is the 1st at midnight UTC, including on the 1st itself', () => {
249
+ assert.deepEqual(monthStartUtc(Date.UTC(2026, 0, 31, 23, 59)), new Date(Date.UTC(2026, 0, 1)));
250
+ assert.deepEqual(monthStartUtc(Date.UTC(2026, 0, 1, 0, 0)), new Date(Date.UTC(2026, 0, 1)));
251
+ });
252
+
253
+ test('the default cap is a real, small, finite number — a default is what an unthinking instance gets', () => {
254
+ assert.ok(Number.isFinite(DEFAULT_CAP_USD) && DEFAULT_CAP_USD > 0);
255
+ assert.ok(DEFAULT_CAP_USD <= 100, 'generous defaults are how an unbounded bill arrives');
256
+ });
257
+
258
+ test('the ceiling RECORDS NOTHING — the ledger is the only tally', async () => {
259
+ // fire-budget.check deliberately records the fire it admits. This one must not:
260
+ // a second tally is the "two disagreeing answers to what did agents cost" that
261
+ // fire-budget's own header refuses to become.
262
+ const c = createSpendCeiling({ capUsd: 25, now: () => AT });
263
+ const writes = [];
264
+ const pool = { query: async (sql) => { writes.push(sql); return { rows: [{ spent: '1' }] }; } };
265
+ await c.check(pool, PROJECT);
266
+ await c.check(pool, PROJECT);
267
+ assert.equal(writes.length, 2, 'two reads');
268
+ for (const sql of writes) {
269
+ assert.match(sql, /^\s*SELECT/i, 'reads only');
270
+ assert.doesNotMatch(sql, /INSERT|UPDATE|DELETE/i);
271
+ }
272
+ });