@bongos/core 1.19.732 → 1.19.734
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.
- package/.bongos-core.json +68 -38
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0161-publish-on-merge.md +3 -0
- package/docs/adr/0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md +141 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +22 -0
- package/modules/agents/lib/spend-ceiling.js +170 -0
- package/modules/agents/migrations/agents_002_payer.sql +87 -0
- package/modules/agents/module.json +2 -1
- package/modules/agents/routes/agents.js +62 -1
- package/modules/agents/spawn.js +21 -3
- package/modules/grading/dead-feature-claim.js +121 -0
- package/modules/grading/grader-score.js +32 -2
- package/modules/grading/grader-workers/worker-verdict.js +12 -2
- package/modules/provisioning/provisioning.js +17 -1
- package/modules/provisioning/tests/provisioning.mjs +8 -8
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bump-version.js +14 -5
- package/scripts/gds/exec-path-guard.js +3 -3
- package/src/branding.js +13 -0
- package/src/module-api.js +1 -1
- package/src/module-loader/loader.js +6 -0
- package/src/module-loader/manifest-schema.js +30 -0
- package/src/modules.js +107 -2
- package/tests/agents_spend_guard.mjs +272 -0
- package/tests/agents_write_routes.mjs +51 -1
- package/tests/bump_version.mjs +78 -1
- package/tests/currency_label.mjs +9 -4
- package/tests/dead_feature_claim.mjs +176 -0
- package/tests/grade_server_authoritative.mjs +57 -0
- package/tests/provision_settings_apply.mjs +11 -5
- package/tests/provisioning_settings.mjs +11 -7
- package/tests/provisioning_settings_apply.mjs +4 -4
- package/tests/provisioning_settings_env.mjs +5 -1
|
@@ -173,12 +173,28 @@ const RESTART_SHAPES = new Set(['co-tenant', 'standalone']);
|
|
|
173
173
|
// and coherent because strict holds only the pin: the worst case is a project whose
|
|
174
174
|
// deploy waits on a review nobody has done, which is visible and owner-releasable,
|
|
175
175
|
// never a stranded builder.
|
|
176
|
+
// `spend_payer` is the project's answer to "who pays when a module spends money
|
|
177
|
+
// on a caller's behalf?" (task 1003884). `project` puts the bill on whoever runs
|
|
178
|
+
// the instance; `builder` on the caller who spent it; `none` is no payer at all.
|
|
179
|
+
//
|
|
180
|
+
// It is the one setting that can REFUSE A MODULE. src/modules.js will not enable a
|
|
181
|
+
// module whose manifest declares `spend.requiresPayer` while this project's join
|
|
182
|
+
// door is `open` and this key is `none` — an account created seconds ago could
|
|
183
|
+
// otherwise spend the instance's money with nobody to bill. The refusal is at
|
|
184
|
+
// config load, so the module's routes never mount at all.
|
|
185
|
+
//
|
|
186
|
+
// Its default IS today's behaviour, unlike `artist_gate` above: no project has
|
|
187
|
+
// ever named a payer, and `none` is what every existing row already means. That
|
|
188
|
+
// costs nothing today because the only module declaring metered spend ships
|
|
189
|
+
// `default: false` — an owner who turns it on is the first person this key ever
|
|
190
|
+
// stops, and the warning tells them which two words fix it.
|
|
176
191
|
const SETTINGS_VOCAB = Object.freeze({
|
|
177
192
|
platform_visibility: ['public', 'gated'],
|
|
178
193
|
joinability: ['open', 'apply', 'invite_only'],
|
|
179
194
|
visibility: ['public', 'private', 'stealth'],
|
|
180
195
|
join_grant: ['view', 'apply', 'full'],
|
|
181
196
|
artist_gate: ['off', 'advisory', 'strict'],
|
|
197
|
+
spend_payer: ['none', 'project', 'builder'],
|
|
182
198
|
});
|
|
183
199
|
// Default = today's behavior on every axis, including the join door: ADR 0182's
|
|
184
200
|
// migration gives the hub column the same `public` default, "existing rows keep
|
|
@@ -186,7 +202,7 @@ const SETTINGS_VOCAB = Object.freeze({
|
|
|
186
202
|
// owner's ruling rather than the status quo — see the note above it.
|
|
187
203
|
const DEFAULT_SETTINGS = Object.freeze({
|
|
188
204
|
platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full',
|
|
189
|
-
artist_gate: 'strict',
|
|
205
|
+
artist_gate: 'strict', spend_payer: 'none',
|
|
190
206
|
});
|
|
191
207
|
|
|
192
208
|
// The project's PLANET (task 1003347, direction record §16): how the platform
|
|
@@ -263,27 +263,27 @@ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no
|
|
|
263
263
|
console.log('\nPer-project settings (task 1003040):');
|
|
264
264
|
|
|
265
265
|
t('effectiveSettings: defaults for a bare/empty row = today\'s behavior; stored keys overlay; junk falls back', () => {
|
|
266
|
-
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
|
|
267
|
-
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
|
|
268
|
-
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
|
|
266
|
+
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
267
|
+
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
268
|
+
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
269
269
|
assert.deepEqual(
|
|
270
270
|
P.effectiveSettings({ settings: { platform_visibility: 'gated' } }),
|
|
271
|
-
{ platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' },
|
|
271
|
+
{ platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' },
|
|
272
272
|
'a stored key overlays only itself'
|
|
273
273
|
);
|
|
274
274
|
assert.deepEqual(
|
|
275
275
|
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted', join_grant: 'everything' } }),
|
|
276
|
-
{ platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict' },
|
|
276
|
+
{ platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' },
|
|
277
277
|
'off-vocabulary values fall back to the default; unknown keys never leak out'
|
|
278
278
|
);
|
|
279
279
|
assert.deepEqual(P.effectiveSettings({ settings: ['not', 'an', 'object'] }),
|
|
280
|
-
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'a non-object settings value reads as defaults');
|
|
280
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'a non-object settings value reads as defaults');
|
|
281
281
|
// the join door reads back like any other policy key (task 1002324)
|
|
282
282
|
assert.deepEqual(P.effectiveSettings({ settings: { visibility: 'stealth' } }),
|
|
283
|
-
{ platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict' }, 'a stored door overlays only itself');
|
|
283
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'a stored door overlays only itself');
|
|
284
284
|
// what a public join grants is a policy key like any other (task 1002331)
|
|
285
285
|
assert.deepEqual(P.effectiveSettings({ settings: { join_grant: 'view' } }),
|
|
286
|
-
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'view', artist_gate: 'strict' }, 'the grant overlays only itself');
|
|
286
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'view', artist_gate: 'strict', spend_payer: 'none' }, 'the grant overlays only itself');
|
|
287
287
|
});
|
|
288
288
|
|
|
289
289
|
t('SETTINGS_VOCAB / DEFAULT_SETTINGS: every default is on its own vocabulary (lockstep guard)', () => {
|
|
@@ -189,6 +189,7 @@ const STUB_VOCAB = {
|
|
|
189
189
|
visibility: ['public', 'private', 'stealth'],
|
|
190
190
|
join_grant: ['view', 'apply', 'full'],
|
|
191
191
|
artist_gate: ['off', 'advisory', 'strict'],
|
|
192
|
+
spend_payer: ['none', 'project', 'builder'],
|
|
192
193
|
planet_template: null, // the template set is long; shape is checked, not membership
|
|
193
194
|
planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
|
|
194
195
|
card_backdrop: ['nebula', 'dust', 'deepfield', 'chrome'],
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.734",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.734",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.734",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -138,11 +138,20 @@ function isReleaseCommit(subject) {
|
|
|
138
138
|
// exactly the distinction the constant was written to make.
|
|
139
139
|
//
|
|
140
140
|
// BOTH failure directions are safe, which is what makes this acceptable on a
|
|
141
|
-
// release-critical lane
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
141
|
+
// release-critical lane — but only since task 1003914 reordered publish.yml so
|
|
142
|
+
// the bump commit is PUSHED AFTER a successful publish, not before it:
|
|
143
|
+
// • pack or publish failed → the bump commit was never pushed (it only ever
|
|
144
|
+
// existed on the discarded runner), so no version looks taken and the next
|
|
145
|
+
// run recomputes and retries the SAME version. No gap, nothing burned.
|
|
146
|
+
// • published, push then failed → version looks free, so the next run bumps to
|
|
147
|
+
// it and npm refuses the duplicate. publish.yml treats that refusal as
|
|
148
|
+
// success and falls through to the record step, which commits the release
|
|
149
|
+
// line main was missing. Self-healing, and nothing wrong ships.
|
|
150
|
+
//
|
|
151
|
+
// Before that reorder the first case was the lossy one: the push landed first,
|
|
152
|
+
// so a failed publish left main declaring a version the registry never had, the
|
|
153
|
+
// cascade terminator no-opped every following run, and only a fresh work merge
|
|
154
|
+
// moved past it. That is how 1.19.716-723 were minted and lost (2026-09-13).
|
|
146
155
|
function laneReleasedVersions(subjects = []) {
|
|
147
156
|
const out = new Set();
|
|
148
157
|
for (const subject of subjects) {
|
|
@@ -91,7 +91,7 @@ const EXEMPTIONS = [
|
|
|
91
91
|
+ 're-opens for review.',
|
|
92
92
|
},
|
|
93
93
|
{
|
|
94
|
-
at: 'src/module-loader/loader.js:
|
|
94
|
+
at: 'src/module-loader/loader.js:196',
|
|
95
95
|
why: 'The plugin loader, and the second site the audit named (B50 "motivating sites"). It '
|
|
96
96
|
+ 'require()s a discovered module\'s route file, so the specifier is a runtime path BY '
|
|
97
97
|
+ 'DESIGN — a plugin system whose module paths are compile-time constants is not a plugin '
|
|
@@ -103,8 +103,8 @@ const EXEMPTIONS = [
|
|
|
103
103
|
+ 'absorbed into this check.',
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
|
-
at: 'src/module-loader/loader.js:
|
|
107
|
-
why: 'The poller twin of the route require at :
|
|
106
|
+
at: 'src/module-loader/loader.js:299',
|
|
107
|
+
why: 'The poller twin of the route require at :196 — same DEFAULT_ROOTS bound, same plugin-by-'
|
|
108
108
|
+ 'design rationale, same ADR 0107 / task 1003400 follow-on. Listed on its own line so a '
|
|
109
109
|
+ 'change to either call re-opens only that one.',
|
|
110
110
|
},
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.734'; // 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({
|
|
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,
|