@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.
- package/.bongos-core.json +61 -36
- 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/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/scripts/gds/package-core.js +31 -10
- 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/leak_scan_fail_closed.mjs +108 -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
|
@@ -42,7 +42,7 @@ const ARMED = Object.freeze({
|
|
|
42
42
|
created_at: 'T0', updated_at: 'T0',
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
function fakePool({ definition = ARMED, runId = 42 } = {}) {
|
|
45
|
+
function fakePool({ definition = ARMED, runId = 42, spentUsd = '0' } = {}) {
|
|
46
46
|
const calls = [];
|
|
47
47
|
return {
|
|
48
48
|
calls,
|
|
@@ -60,6 +60,12 @@ function fakePool({ definition = ARMED, runId = 42 } = {}) {
|
|
|
60
60
|
if (/^\s*INSERT INTO agents_definitions/.test(sql)) return { rows: [{ ...ARMED, name: params[0], enabled: false }] };
|
|
61
61
|
if (/^\s*UPDATE agents_definitions/.test(sql)) return { rows: [{ ...definition, enabled: /enabled = true/.test(sql) }], rowCount: 1 };
|
|
62
62
|
if (/^\s*DELETE FROM agents_definitions/.test(sql)) return { rows: [], rowCount: 1 };
|
|
63
|
+
// THE SPEND CEILING'S READ (task 1003884), and it must be tested BEFORE the
|
|
64
|
+
// generic agents_runs branch below — the same anchoring lesson that branch's
|
|
65
|
+
// own comment records about SELECT vs DELETE. Matched last, the ceiling's
|
|
66
|
+
// SUM would be answered with a ledger ROW, which has no `spent` column, and
|
|
67
|
+
// the ceiling would correctly refuse every fire in this file as unreadable.
|
|
68
|
+
if (/SUM\(cost_usd\)/.test(sql)) return { rows: [{ spent: spentUsd }] };
|
|
63
69
|
if (/FROM agents_runs/.test(sql)) {
|
|
64
70
|
// The ownership filter is params[1]; the fake honours it so the IDOR test
|
|
65
71
|
// exercises the real WHERE clause rather than a stub that ignores it.
|
|
@@ -184,6 +190,50 @@ await t('the persona and the caller\'s input BOTH reach the model', async () =>
|
|
|
184
190
|
assert.match(lastPrompt, /why is it so\?/);
|
|
185
191
|
});
|
|
186
192
|
|
|
193
|
+
await t('the fire records WHO PAYS beside who asked (task 1003884)', async () => {
|
|
194
|
+
const pool = fakePool();
|
|
195
|
+
api.pool = pool;
|
|
196
|
+
const res = fakeRes();
|
|
197
|
+
await handler(routes(), 'post', '/agents/:name/invoke')(req({ params: { name: 'historian' } }), res);
|
|
198
|
+
const row = pool.insertedRun();
|
|
199
|
+
assert.equal(row.requested_by_builder_id, 5, 'who asked');
|
|
200
|
+
// The neutral pack ships spend_payer 'none', which attributes to the PROJECT —
|
|
201
|
+
// that is who the money actually comes from, and it is what keeps a closed-door
|
|
202
|
+
// instance working exactly as it did (ADR 0282).
|
|
203
|
+
assert.equal(row.payer_kind, 'project', 'who is on the hook');
|
|
204
|
+
assert.equal(row.payer_builder_id, null);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await t('A PAYER AT THE CAP IS REFUSED 402, and nothing is spawned or ledgered', async () => {
|
|
208
|
+
// The ceiling is wired ahead of the definition read, so an exhausted payer costs
|
|
209
|
+
// not even a SELECT. 402 rather than fire-budget's 429 on purpose: "the money is
|
|
210
|
+
// gone" and "too fast, try later" are different operator problems.
|
|
211
|
+
const pool = fakePool({ spentUsd: '9999' });
|
|
212
|
+
api.pool = pool;
|
|
213
|
+
lastPrompt = null;
|
|
214
|
+
const res = fakeRes();
|
|
215
|
+
await handler(routes(), 'post', '/agents/:name/invoke')(req({ params: { name: 'historian' } }), res);
|
|
216
|
+
|
|
217
|
+
assert.equal(res.out.status, 402);
|
|
218
|
+
assert.equal(res.out.failed.code, 'spend_cap_reached');
|
|
219
|
+
assert.equal(res.out.failed.details.reason, 'spend_cap_reached');
|
|
220
|
+
assert.equal(res.out.failed.details.payer_kind, 'project');
|
|
221
|
+
assert.equal(pool.insertedRun(), null, 'a refused fire writes no run');
|
|
222
|
+
assert.equal(lastPrompt, null, 'and calls no model');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await t('AN UNREADABLE LEDGER REFUSES THE FIRE — the ceiling fails closed at the route too', async () => {
|
|
226
|
+
// The lib-level case is in tests/agents_spend_guard.mjs; this one proves the
|
|
227
|
+
// route honours it rather than falling through, which is the half that task
|
|
228
|
+
// 1001393's failed-open brake actually got wrong.
|
|
229
|
+
api.pool = { async query() { throw new Error('ECONNREFUSED'); } };
|
|
230
|
+
const res = fakeRes();
|
|
231
|
+
await handler(routes(), 'post', '/agents/:name/invoke')(req({ params: { name: 'historian' } }), res);
|
|
232
|
+
assert.equal(res.out.status, 402);
|
|
233
|
+
assert.equal(res.out.failed.details.reason, 'ledger_unreadable');
|
|
234
|
+
assert.equal(res.out.failed.details.spent_usd, null, 'it claims no sum it does not have');
|
|
235
|
+
});
|
|
236
|
+
|
|
187
237
|
await t('a body key the schema does not name is refused rather than ignored', async () => {
|
|
188
238
|
api.pool = fakePool();
|
|
189
239
|
const res = fakeRes();
|
package/tests/bump_version.mjs
CHANGED
|
@@ -171,7 +171,84 @@ test('publish.yml wires the terminator: guard step present, every armed step gat
|
|
|
171
171
|
assert.ok(PUBLISH_YML.includes('id: relhead'), 'the Already released? guard step exists');
|
|
172
172
|
assert.match(PUBLISH_YML, /isReleaseCommit/, 'the guard delegates to bump-version.js — single source');
|
|
173
173
|
const gates = (PUBLISH_YML.match(/steps\.relhead\.outputs\.release_head == 'false'/g) || []).length;
|
|
174
|
-
assert.ok(gates >= 7, `every post-guard armed step must carry the gate (setup-node, install, resolve, bump, pack, publish, tag) — found ${gates}`);
|
|
174
|
+
assert.ok(gates >= 7, `every post-guard armed step must carry the gate (setup-node, install, resolve, bump, pack, publish, record, tag) — found ${gates}`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---- the release ORDER: publish before push (task 1003914) ----
|
|
178
|
+
//
|
|
179
|
+
// The bug this pins: publish.yml used to push the bump commit to main BEFORE
|
|
180
|
+
// packaging and publishing. A failure in either left main permanently declaring
|
|
181
|
+
// a version that never reached npm, and the cascade terminator above then made
|
|
182
|
+
// it unretryable — HEAD was this lane's own release commit, so every following
|
|
183
|
+
// run no-opped and only a fresh work merge moved past the hole. 1.19.716-723
|
|
184
|
+
// were minted and lost that way on 2026-09-13.
|
|
185
|
+
//
|
|
186
|
+
// These are source-order assertions because a workflow's step order IS its
|
|
187
|
+
// source; there is nothing to execute here. They key on the actual COMMANDS,
|
|
188
|
+
// never on prose, so a comment rewrite can't make them pass falsely.
|
|
189
|
+
|
|
190
|
+
const BRANCH_PUSH = ['git', 'push', 'origin', 'HEAD:main'].join(' ');
|
|
191
|
+
|
|
192
|
+
test('the bump commit is pushed only AFTER npm publish — the irreversible step goes last', () => {
|
|
193
|
+
const publishAt = PUBLISH_YML.indexOf('npm publish "$TGZ"');
|
|
194
|
+
const pushAt = PUBLISH_YML.indexOf(BRANCH_PUSH);
|
|
195
|
+
assert.ok(publishAt !== -1, 'publish.yml must publish the packaged tarball');
|
|
196
|
+
assert.ok(pushAt !== -1, 'publish.yml must push the bump commit to main somewhere');
|
|
197
|
+
assert.ok(
|
|
198
|
+
publishAt < pushAt,
|
|
199
|
+
'the push to main must come AFTER npm publish. Pushing first makes a failed publish burn the version number permanently and unretryably (task 1003914).',
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('the push to main happens exactly once, and not inside the bump step', () => {
|
|
204
|
+
const pushes = PUBLISH_YML.split(BRANCH_PUSH).length - 1;
|
|
205
|
+
assert.equal(pushes, 1, `exactly one push of the bump to main — found ${pushes}`);
|
|
206
|
+
// The bump step runs from `id: bump` up to the next step's `- name:`.
|
|
207
|
+
const bumpAt = PUBLISH_YML.indexOf('id: bump');
|
|
208
|
+
assert.ok(bumpAt !== -1, 'the bump step must carry id: bump');
|
|
209
|
+
const nextStepAt = PUBLISH_YML.indexOf('\n - name:', bumpAt);
|
|
210
|
+
const bumpStep = PUBLISH_YML.slice(bumpAt, nextStepAt === -1 ? undefined : nextStepAt);
|
|
211
|
+
assert.ok(
|
|
212
|
+
!bumpStep.includes(BRANCH_PUSH),
|
|
213
|
+
'the bump step must COMMIT only — its push moved to the record step so a pack/publish failure discards a purely local commit',
|
|
214
|
+
);
|
|
215
|
+
assert.ok(bumpStep.includes('git commit -m'), 'the bump step still commits locally — package-core stamps the version found AT the ref');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('an already-published version is success, not a wedge', () => {
|
|
219
|
+
// Publish-then-failed-push leaves a version on npm with no release commit.
|
|
220
|
+
// The next run reads it as free (laneReleasedVersions is git-based), bumps to
|
|
221
|
+
// it, and npm refuses the duplicate. Without this branch that run fails, and
|
|
222
|
+
// so does every run after it — the lane wedges with no way out but a human.
|
|
223
|
+
const pubAt = PUBLISH_YML.indexOf('id: pub');
|
|
224
|
+
assert.ok(pubAt !== -1, 'the publish step must carry id: pub');
|
|
225
|
+
const pubStep = PUBLISH_YML.slice(pubAt, PUBLISH_YML.indexOf('\n # PUBLISH VERDICT'));
|
|
226
|
+
assert.match(
|
|
227
|
+
pubStep,
|
|
228
|
+
/cannot publish over\|previously published\|EPUBLISHCONFLICT/,
|
|
229
|
+
'the publish step must recognise npm refusing a duplicate version',
|
|
230
|
+
);
|
|
231
|
+
assert.match(pubStep, /already=true/, 'and must record that it took the already-present branch');
|
|
232
|
+
assert.match(
|
|
233
|
+
pubStep,
|
|
234
|
+
/exit "\$RC"/,
|
|
235
|
+
'every OTHER publish failure must still fail the step — the tolerance is narrow on purpose',
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('the record step gates on a bump actually having been made', () => {
|
|
240
|
+
const recAt = PUBLISH_YML.indexOf('- name: Record the release on main');
|
|
241
|
+
assert.ok(recAt !== -1, 'the record step exists');
|
|
242
|
+
const recStep = PUBLISH_YML.slice(recAt, PUBLISH_YML.indexOf('\n # PUBLISH VERDICT'));
|
|
243
|
+
assert.match(
|
|
244
|
+
recStep,
|
|
245
|
+
/steps\.bump\.outputs\.to != ''/,
|
|
246
|
+
'nothing to push when no bump was made (taken == false) — the step must skip rather than push an empty HEAD',
|
|
247
|
+
);
|
|
248
|
+
assert.ok(
|
|
249
|
+
!recStep.includes('git rebase'),
|
|
250
|
+
'no rebase-retry: the tarball was built from the pre-rebase tree, so rebasing would leave source_commit naming a SHA that is not on main',
|
|
251
|
+
);
|
|
175
252
|
});
|
|
176
253
|
|
|
177
254
|
// ---------------------------------------------------------------------------
|
package/tests/currency_label.mjs
CHANGED
|
@@ -54,11 +54,16 @@ test('clientBranding exposes project policy — the settings read-back (task 100
|
|
|
54
54
|
// `artistGate` joined in task 1003575 (ADR 0241) and is the ONE key here whose
|
|
55
55
|
// fallback is not today's behaviour: 'strict' is the owner's ruling, safe as a
|
|
56
56
|
// default because it can only ever delay a pin move, never a builder's ship.
|
|
57
|
+
// `spendPayer` joined in task 1003884 (ADR 0282) — who pays when a module spends
|
|
58
|
+
// money for whoever called it. Its fallback IS today's behaviour ('none'), and it
|
|
59
|
+
// is published for a reason of its own: it is the one key that can REFUSE a
|
|
60
|
+
// module, so an owner looking at a module that will not turn on needs the
|
|
61
|
+
// instance's own answer rather than the platform's record of what they saved.
|
|
57
62
|
assert.deepEqual(clientBranding({}).project,
|
|
58
|
-
{ platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full', artistGate: 'strict' });
|
|
63
|
+
{ platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' });
|
|
59
64
|
assert.deepEqual(
|
|
60
|
-
clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view', artistGate: 'off' } }).project,
|
|
61
|
-
{ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view', artistGate: 'off' },
|
|
65
|
+
clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view', artistGate: 'off', spendPayer: 'builder' } }).project,
|
|
66
|
+
{ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view', artistGate: 'off', spendPayer: 'builder' },
|
|
62
67
|
'what the instance actually resolved (env override included) is what it publishes');
|
|
63
68
|
});
|
|
64
69
|
|
|
@@ -81,7 +86,7 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
|
|
|
81
86
|
// public for the same read-back reason as its neighbours. Its companion stamp
|
|
82
87
|
// (artistGateSince) deliberately does NOT: that is platform bookkeeping the server
|
|
83
88
|
// reads, and no browser surface has a question it answers.
|
|
84
|
-
assert.deepEqual(Object.keys(projected.project).sort(), ['artistGate', 'joinGrant', 'joinability', 'platformVisibility', 'visibility']);
|
|
89
|
+
assert.deepEqual(Object.keys(projected.project).sort(), ['artistGate', 'joinGrant', 'joinability', 'platformVisibility', 'spendPayer', 'visibility']);
|
|
85
90
|
for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
|
|
86
91
|
assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
|
|
87
92
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// tests/leak_scan_fail_closed.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003870 — the post-build leak scan must FAIL CLOSED. The detector (task 1002220) and
|
|
4
|
+
// the neutral floor (task 1002217) both landed, but strictness was opt-in and the publish lane
|
|
5
|
+
// never opted in, so a leaky artifact could have gone to npm while the workflow's own header
|
|
6
|
+
// comment claimed a fail-closed gate. This locks both halves:
|
|
7
|
+
// 1. the POLICY — planting a prior-instance literal makes the build refuse, with no flag,
|
|
8
|
+
// 2. the WIRING — the publish lane still arms the gate and never disarms it.
|
|
9
|
+
// (2) is the half that was actually broken, and a policy test alone would not have caught it.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/leak_scan_fail_closed.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { scanArtifactForLeaks, leakScanVerdict, PRIOR_INSTANCE_FLOOR } = require('../scripts/gds/package-core.js');
|
|
21
|
+
|
|
22
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
|
|
24
|
+
// This file is itself published, so it DERIVES the literal it plants instead of spelling it
|
|
25
|
+
// out — the self-leak invariant the allowlist and mirror-redact both hold about themselves.
|
|
26
|
+
// It also means a future rename of the prior instance cannot quietly gut this test: the plant
|
|
27
|
+
// follows the floor, rather than pinning a string that would stop matching.
|
|
28
|
+
const TOKEN = PRIOR_INSTANCE_FLOOR.tokens[0];
|
|
29
|
+
const OWNER = PRIOR_INSTANCE_FLOOR.owners[0];
|
|
30
|
+
|
|
31
|
+
let passed = 0, failed = 0;
|
|
32
|
+
function t(name, fn) {
|
|
33
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
34
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log('\nthe planted literal makes the build refuse (no flag required):');
|
|
38
|
+
|
|
39
|
+
t('a planted prior-instance token in a functional file is FATAL by default', () => {
|
|
40
|
+
const files = [{ path: 'src/some-module.js', content: `const host = "${TOKEN}";\n` }];
|
|
41
|
+
const scan = scanArtifactForLeaks(files);
|
|
42
|
+
assert.ok(scan.literalHits.length > 0, 'the scan must see the planted token');
|
|
43
|
+
const v = leakScanVerdict(scan.literalHits, ['node', 'package-core.js']);
|
|
44
|
+
assert.equal(v.run, true, 'the scan runs when no escape flag is passed');
|
|
45
|
+
assert.equal(v.fatal, true, 'THE LOAD-BEARING ASSERTION: a leak aborts the build with no flag');
|
|
46
|
+
assert.equal(v.reason, 'prior-instance-literals');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
t('a planted prior-instance OWNER is fatal too, not just the token', () => {
|
|
50
|
+
const files = [{ path: 'scripts/thing.js', content: `// maintained by ${OWNER}\n` }];
|
|
51
|
+
const scan = scanArtifactForLeaks(files);
|
|
52
|
+
assert.ok(scan.literalHits.length > 0, 'the scan must see the planted owner');
|
|
53
|
+
assert.equal(leakScanVerdict(scan.literalHits, ['node', 'x']).fatal, true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
t('a clean artifact is NOT fatal (the gate does not fire on everything)', () => {
|
|
57
|
+
const files = [{ path: 'src/clean.js', content: 'const x = 1;\n' }];
|
|
58
|
+
const scan = scanArtifactForLeaks(files);
|
|
59
|
+
assert.equal(scan.literalHits.length, 0);
|
|
60
|
+
const v = leakScanVerdict(scan.literalHits, ['node', 'x']);
|
|
61
|
+
assert.equal(v.fatal, false);
|
|
62
|
+
assert.equal(v.reason, 'clean');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
t('--leak-scan-strict is a no-op: it cannot be the thing that makes it fatal', () => {
|
|
66
|
+
const files = [{ path: 'src/some-module.js', content: `x = "${TOKEN}"\n` }];
|
|
67
|
+
const hits = scanArtifactForLeaks(files).literalHits;
|
|
68
|
+
const withFlag = leakScanVerdict(hits, ['node', 'x', '--leak-scan-strict']);
|
|
69
|
+
const without = leakScanVerdict(hits, ['node', 'x']);
|
|
70
|
+
assert.deepEqual(withFlag, without, 'strict must add nothing — fatal is the default now');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
t('--no-leak-scan is the only escape, and it skips rather than downgrades', () => {
|
|
74
|
+
const files = [{ path: 'src/some-module.js', content: `x = "${TOKEN}"\n` }];
|
|
75
|
+
const hits = scanArtifactForLeaks(files).literalHits;
|
|
76
|
+
const v = leakScanVerdict(hits, ['node', 'x', '--no-leak-scan']);
|
|
77
|
+
assert.equal(v.run, false, 'the scan does not run at all');
|
|
78
|
+
assert.equal(v.fatal, false);
|
|
79
|
+
assert.equal(v.reason, 'skipped');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
console.log('\nthe publish lane arms the gate (the half that was actually broken):');
|
|
83
|
+
|
|
84
|
+
const publishYml = readFileSync(join(ROOT, '.github/workflows/publish.yml'), 'utf8');
|
|
85
|
+
const packLine = publishYml.split('\n').find((l) => l.includes('package-core.js') && l.includes('--ref HEAD'));
|
|
86
|
+
|
|
87
|
+
t('publish.yml still invokes package-core to build the release artifact', () => {
|
|
88
|
+
assert.ok(packLine, 'no package-core invocation found — the lane was rewired, re-check this test');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
t('the publish invocation arms the leak gate explicitly', () => {
|
|
92
|
+
assert.ok(packLine.includes('--leak-scan-strict'),
|
|
93
|
+
'the publish lane must state the gate it depends on, even though fatal is the default');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
t('nothing the publish lane RUNS disarms the leak scan', () => {
|
|
97
|
+
// Executable lines only: a YAML/shell comment may legitimately name the escape flag while
|
|
98
|
+
// explaining why the lane never uses it, and matching prose would fail on its own docs.
|
|
99
|
+
const executable = publishYml
|
|
100
|
+
.split('\n')
|
|
101
|
+
.filter((l) => !/^\s*#/.test(l))
|
|
102
|
+
.join('\n');
|
|
103
|
+
assert.ok(!executable.includes('--no-leak-scan'),
|
|
104
|
+
'the one lane that makes an artifact permanent and public must never skip the scan');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
console.log(`\n${passed} passed, ${failed} failed\n`);
|
|
108
|
+
if (failed) process.exit(1);
|
|
@@ -62,6 +62,7 @@ ${NEUTRAL_PREFIX}_JOINABILITY=apply
|
|
|
62
62
|
${NEUTRAL_PREFIX}_VISIBILITY=public
|
|
63
63
|
${NEUTRAL_PREFIX}_JOIN_GRANT=full
|
|
64
64
|
${NEUTRAL_PREFIX}_ARTIST_GATE=strict
|
|
65
|
+
${NEUTRAL_PREFIX}_SPEND_PAYER=none
|
|
65
66
|
${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE=
|
|
66
67
|
`;
|
|
67
68
|
|
|
@@ -120,7 +121,12 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
|
|
|
120
121
|
[`${NEUTRAL_PREFIX}_VISIBILITY`]: 'public', [`${NEUTRAL_PREFIX}_JOIN_GRANT`]: 'full',
|
|
121
122
|
// the artist gate + its non-retroactivity stamp (task 1003575, ADR 0241) ride
|
|
122
123
|
// the same transport; the stamp is empty until the gate is actually RAISED
|
|
123
|
-
[`${NEUTRAL_PREFIX}_ARTIST_GATE`]: 'strict',
|
|
124
|
+
[`${NEUTRAL_PREFIX}_ARTIST_GATE`]: 'strict',
|
|
125
|
+
// who pays for metered module spend (task 1003884, ADR 0282) rides it too —
|
|
126
|
+
// it has to, because src/modules.js reads it at CONFIG LOAD, so the instance
|
|
127
|
+
// must already hold it in web.env before its first require
|
|
128
|
+
[`${NEUTRAL_PREFIX}_SPEND_PAYER`]: 'none',
|
|
129
|
+
[`${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE`]: '',
|
|
124
130
|
});
|
|
125
131
|
const body = P.webEnvBody(inst, {});
|
|
126
132
|
for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
|
|
@@ -132,10 +138,10 @@ const GATED = { settings: { platform_visibility: 'gated', joinability: 'invite_o
|
|
|
132
138
|
const manifestWith = (project) => JSON.stringify({ schema: 'instance-manifest/v1', brand: project === undefined ? {} : { project } });
|
|
133
139
|
|
|
134
140
|
t('true when every pushed key matches brand.project (camelCase, the ENV_OVERRIDES targets)', () => {
|
|
135
|
-
assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict' }), GATED), true);
|
|
141
|
+
assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' }), GATED), true);
|
|
136
142
|
});
|
|
137
143
|
t('false when the instance still reports a different value', () => {
|
|
138
|
-
assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict' }), GATED), false);
|
|
144
|
+
assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' }), GATED), false);
|
|
139
145
|
assert.equal(P.settingsConsumed(manifestWith({ joinability: 'invite_only' }), GATED), false, 'a missing key is a mismatch, not a pass');
|
|
140
146
|
});
|
|
141
147
|
t('null (unknown) when the manifest carries no project block — a core that predates task 1003139 — or does not parse', () => {
|
|
@@ -153,7 +159,7 @@ t('instanceManifestCmd asks the instance on loopback, like healthzCmd', () => {
|
|
|
153
159
|
function recorder({ apply = false, envBody = LIVE_ENV, readFails = false, healthzFails = false, privileged = false,
|
|
154
160
|
manifest = 'match', restartThrows = false, writeThrows = false } = {}) {
|
|
155
161
|
const cmds = [], writes = [], events = [], probes = [], cleared = [], logs = [];
|
|
156
|
-
const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict' })
|
|
162
|
+
const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' })
|
|
157
163
|
: manifest === 'mismatch' ? manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public' })
|
|
158
164
|
: manifest === 'none' ? manifestWith(undefined) : 'nope';
|
|
159
165
|
const exec = (cmd, opts = {}) => {
|
|
@@ -222,7 +228,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
|
|
|
222
228
|
assert.equal(r.ok, true);
|
|
223
229
|
assert.equal(r.healthy, null, 'nothing ran, nothing observed');
|
|
224
230
|
assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
|
|
225
|
-
assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_ARTIST_GATE_SINCE in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
|
|
231
|
+
assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_ARTIST_GATE_SINCE in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
|
|
226
232
|
assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
|
|
227
233
|
assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
|
|
228
234
|
});
|
|
@@ -164,7 +164,7 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
|
|
|
164
164
|
reset();
|
|
165
165
|
const r = await get('/provisioning/instances/18/settings');
|
|
166
166
|
assert.equal(r.status, 200, r.raw);
|
|
167
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
|
|
167
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
168
168
|
// the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
|
|
169
169
|
assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
170
170
|
assert.deepEqual(r.body.options, {
|
|
@@ -179,6 +179,10 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
|
|
|
179
179
|
// offered to the form like the rest; what makes it different is only its
|
|
180
180
|
// default, which is the owner's ruling rather than today's behaviour.
|
|
181
181
|
artist_gate: ['off', 'advisory', 'strict'],
|
|
182
|
+
// who pays for metered module spend (task 1003884, ADR 0282) — the sixth
|
|
183
|
+
// policy axis, offered to the form like the rest. What makes it different is
|
|
184
|
+
// its CONSEQUENCE: it is the only setting that can refuse a module outright.
|
|
185
|
+
spend_payer: ['none', 'project', 'builder'],
|
|
182
186
|
planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
|
|
183
187
|
// the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
|
|
184
188
|
// closed list, no default, no push — so it rides the same vocab
|
|
@@ -191,14 +195,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
|
|
|
191
195
|
const r = await get('/provisioning/instances/18/settings');
|
|
192
196
|
assert.equal(r.status, 200, r.raw);
|
|
193
197
|
assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
|
|
194
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the planet is not a setting: the policy read is untouched');
|
|
198
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the planet is not a setting: the policy read is untouched');
|
|
195
199
|
});
|
|
196
200
|
|
|
197
201
|
await ta('stored keys overlay the defaults', async () => {
|
|
198
202
|
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
|
|
199
203
|
const r = await get('/provisioning/instances/18/settings');
|
|
200
204
|
assert.equal(r.status, 200, r.raw);
|
|
201
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
|
|
205
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
202
206
|
});
|
|
203
207
|
|
|
204
208
|
await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
|
|
@@ -226,7 +230,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
|
|
|
226
230
|
reset();
|
|
227
231
|
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
228
232
|
assert.equal(r.status, 200, r.raw);
|
|
229
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
|
|
233
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
|
|
230
234
|
assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
|
|
231
235
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
|
|
232
236
|
assert.equal(events.length, 1);
|
|
@@ -239,7 +243,7 @@ await ta('owner changes both keys in one call', async () => {
|
|
|
239
243
|
reset();
|
|
240
244
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
|
|
241
245
|
assert.equal(r.status, 200, r.raw);
|
|
242
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
|
|
246
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
243
247
|
});
|
|
244
248
|
|
|
245
249
|
await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
|
|
@@ -278,7 +282,7 @@ await ta('the owner sets the door to stealth — stored, echoed effective, audit
|
|
|
278
282
|
reset();
|
|
279
283
|
const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
|
|
280
284
|
assert.equal(r.status, 200, r.raw);
|
|
281
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict' });
|
|
285
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
282
286
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
|
|
283
287
|
assert.equal(events[0].actor, 'api:self');
|
|
284
288
|
assert.match(events[0].detail, /visibility=stealth/);
|
|
@@ -338,7 +342,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
|
|
|
338
342
|
const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
|
|
339
343
|
assert.equal(r.status, 200, r.raw);
|
|
340
344
|
assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
|
|
341
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the policy half is untouched');
|
|
345
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the policy half is untouched');
|
|
342
346
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
|
|
343
347
|
assert.equal(r.body.push.queued, false);
|
|
344
348
|
assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
|
|
@@ -225,7 +225,7 @@ await ta('ACTIVE standalone: the row is written FIRST, then a settings-apply int
|
|
|
225
225
|
reset();
|
|
226
226
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
227
227
|
assert.equal(r.status, 200, r.raw);
|
|
228
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
|
|
228
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
|
|
229
229
|
assert.equal(r.body.settings, undefined);
|
|
230
230
|
assert.deepEqual(updateCalls.map((c) => c.patch), [{ platform_visibility: 'gated' }]);
|
|
231
231
|
assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the runner reads the row — the write must precede the intent');
|
|
@@ -274,7 +274,7 @@ await ta('an enqueue that THROWS still answers 200 "saved" — never a 500 that
|
|
|
274
274
|
enqueueThrows = new Error('db down');
|
|
275
275
|
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
276
276
|
assert.equal(r.status, 200, r.raw);
|
|
277
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
|
|
277
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
278
278
|
assert.equal(updateCalls.length, 1, 'the write landed');
|
|
279
279
|
receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
|
|
280
280
|
assert.match(r.body.push.message, /Saved, but the restart could not be queued/);
|
|
@@ -287,7 +287,7 @@ await ta('same values as already stored → no write, no event, no intent, "No c
|
|
|
287
287
|
reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', joinability: 'open' } } });
|
|
288
288
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'open' });
|
|
289
289
|
assert.equal(r.status, 200, r.raw);
|
|
290
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
|
|
290
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
291
291
|
assert.equal(updateCalls.length, 0, 'nothing written (no updated_at reshuffle)');
|
|
292
292
|
assert.equal(events.length, 0, 'no "settings changed" event for a change that is not one');
|
|
293
293
|
assert.equal(enqueueCalls.length, 0, 'a whole-form save must not bounce a live project');
|
|
@@ -404,7 +404,7 @@ await ta('save A queues → save B 409s while A RUNS → A settles → the UNCHA
|
|
|
404
404
|
assert.equal(enqueueCalls.length, enqueuedBefore + 1, 'exactly one new intent');
|
|
405
405
|
assert.equal(updateCalls.length, writesBefore, 'nothing re-written — the value was already stored in step 2');
|
|
406
406
|
assert.deepEqual(owedCalls[owedCalls.length - 1], { id: 18, owed: false }, 'and the debt is settled');
|
|
407
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict' },
|
|
407
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' },
|
|
408
408
|
'the value that finally ships is the one the owner saved');
|
|
409
409
|
});
|
|
410
410
|
|
|
@@ -117,13 +117,17 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
|
|
|
117
117
|
assert.deepEqual(vars, {
|
|
118
118
|
CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open', CLOUDBONGOS_VISIBILITY: 'stealth',
|
|
119
119
|
CLOUDBONGOS_JOIN_GRANT: 'view', CLOUDBONGOS_ARTIST_GATE: 'advisory', CLOUDBONGOS_ARTIST_GATE_SINCE: '',
|
|
120
|
+
// Who pays for metered module spend (task 1003884) — it rides the same
|
|
121
|
+
// transport because src/modules.js reads it at CONFIG LOAD, which means the
|
|
122
|
+
// instance must already have it in web.env before the first require.
|
|
123
|
+
CLOUDBONGOS_SPEND_PAYER: 'none',
|
|
120
124
|
});
|
|
121
125
|
// varsOf splits on '=', so an empty value reads as '' on both sides — the point
|
|
122
126
|
// of the comparison is that the standup lines carry exactly the same key set.
|
|
123
127
|
assert.deepEqual(varsOf(provisioning.settingsEnvLines(row, 'CLOUDBONGOS')), vars, 'the standup lines ARE the vars');
|
|
124
128
|
assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), {
|
|
125
129
|
X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
|
|
126
|
-
X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '',
|
|
130
|
+
X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
|
|
127
131
|
}, 'defaults, explicitly');
|
|
128
132
|
});
|
|
129
133
|
|