@bongos/core 1.24.8 → 1.24.10

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 (88) hide show
  1. package/.bongos-core.json +131 -86
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +1 -1
  8. package/docs/api/openapi.json +185 -6
  9. package/docs/api-reference.md +7 -3
  10. package/docs/architecture.md +2 -2
  11. package/docs/copy-inventory.md +58 -28
  12. package/docs/copy-registry.json +333 -33
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/modules-contract.md +3 -3
  15. package/docs/page-inventory.json +34 -4
  16. package/docs/page-readings.json +294 -237
  17. package/migrations/core_287_module_prices_cash_only.sql +24 -0
  18. package/modules/hall-ui/public/ai-studio.html +2 -2
  19. package/modules/hall-ui/public/atlas.html +2 -2
  20. package/modules/hall-ui/public/blockers.html +2 -2
  21. package/modules/hall-ui/public/board-room.html +2 -2
  22. package/modules/hall-ui/public/city.html +2 -2
  23. package/modules/hall-ui/public/collab.html +2 -2
  24. package/modules/hall-ui/public/commands.html +2 -2
  25. package/modules/hall-ui/public/copy-desk.html +2 -2
  26. package/modules/hall-ui/public/craft-mode.js +1 -1
  27. package/modules/hall-ui/public/deploy.html +2 -2
  28. package/modules/hall-ui/public/diagrams.html +2 -2
  29. package/modules/hall-ui/public/drachmae.html +2 -2
  30. package/modules/hall-ui/public/fleet.html +2 -2
  31. package/modules/hall-ui/public/gate.html +2 -2
  32. package/modules/hall-ui/public/goal-map.html +2 -2
  33. package/modules/hall-ui/public/goals.html +2 -2
  34. package/modules/hall-ui/public/government.html +2 -2
  35. package/modules/hall-ui/public/idea.html +2 -2
  36. package/modules/hall-ui/public/ideas.html +2 -2
  37. package/modules/hall-ui/public/index.html +2 -2
  38. package/modules/hall-ui/public/market-watch-rules.html +2 -2
  39. package/modules/hall-ui/public/market-watch-settings.css +217 -0
  40. package/modules/hall-ui/public/market-watch-settings.html +113 -0
  41. package/modules/hall-ui/public/market-watch-settings.js +230 -0
  42. package/modules/hall-ui/public/market-watch-settings.states.json +14 -0
  43. package/modules/hall-ui/public/modules.html +2 -2
  44. package/modules/hall-ui/public/modules.js +5 -0
  45. package/modules/hall-ui/public/my-agents.html +2 -2
  46. package/modules/hall-ui/public/primer.html +2 -2
  47. package/modules/hall-ui/public/profile.html +2 -2
  48. package/modules/hall-ui/public/project-settings.html +2 -2
  49. package/modules/hall-ui/public/ranks.html +2 -2
  50. package/modules/hall-ui/public/roadmap.html +2 -2
  51. package/modules/hall-ui/public/roster.html +2 -2
  52. package/modules/hall-ui/public/sessions.html +2 -2
  53. package/modules/hall-ui/public/settings.html +2 -2
  54. package/modules/hall-ui/public/shell.js +3 -0
  55. package/modules/hall-ui/public/street.html +2 -2
  56. package/modules/hall-ui/public/studio.html +2 -2
  57. package/modules/hall-ui/public/task.html +2 -2
  58. package/modules/hall-ui/public/thinking.html +2 -2
  59. package/modules/hall-ui/public/tweak-editor.html +2 -2
  60. package/modules/hall-ui/public/watch.html +2 -2
  61. package/modules/hall-ui/public/work.html +2 -2
  62. package/modules/hall-ui/records/market-watch-settings.md +11 -0
  63. package/modules/market-watch/module.json +3 -2
  64. package/modules/market-watch/routes/settings.js +128 -0
  65. package/modules/market-watch/settings.js +207 -0
  66. package/modules/provisioning/starter-bundles.js +11 -6
  67. package/modules/ui-design/kit/serve.js +1 -0
  68. package/package-lock.json +2 -2
  69. package/package.json +1 -1
  70. package/release-notes.json +12 -0
  71. package/scripts/bongos/module-store-cli.js +21 -21
  72. package/scripts/bongos/module.js +8 -8
  73. package/src/bongos/module-listing.js +47 -20
  74. package/src/bongos/module-scope-map.js +1 -0
  75. package/src/bongos/module-store.js +20 -12
  76. package/src/bongos/routes/modules.js +31 -21
  77. package/src/bongos/serve-internal.js +2 -2
  78. package/src/module-api.js +1 -1
  79. package/tests/hall_nav.mjs +4 -2
  80. package/tests/hall_page_gate_map.mjs +4 -0
  81. package/tests/market_watch_data_db.mjs +27 -0
  82. package/tests/market_watch_module.mjs +8 -4
  83. package/tests/market_watch_settings.mjs +392 -0
  84. package/tests/module_store_cli.mjs +14 -3
  85. package/tests/module_store_listing.mjs +65 -14
  86. package/tests/module_store_price.mjs +97 -26
  87. package/tests/module_store_registry_migration.mjs +11 -0
  88. package/tests/nav_permission_atoms.mjs +3 -2
@@ -0,0 +1,392 @@
1
+ // tests/market_watch_settings.mjs — the owner's Market Watch settings (task 1004938, goal
2
+ // 1000137): the four routes in modules/market-watch/routes/settings.js, through real HTTP,
3
+ // the route's own owner-seat check and the real error envelope, over an in-memory pool that
4
+ // answers the module's own SQL. The permission atom gate (project.settings.manage, floor
5
+ // metic) is stood in by a fake that admits exactly its floor, since the real one resolves
6
+ // grants from the database; tests/government_parity.mjs proves the real one matches the
7
+ // floor. So a Xenos is refused by the atom and a Metic, who holds it, by the owner seat.
8
+ //
9
+ // What it proves, in the task's words:
10
+ // - a non-owner cannot change anything: every route answers 403 to a Metic and a Xenos,
11
+ // and the stored settings and key are unchanged afterwards;
12
+ // - turning Market Watch off stops the next run, asserted against the run's OWN gate:
13
+ // run.js runDue reading settings through store.js createStore over the same pool the
14
+ // route wrote, with the key read back by key.js readProjectKey;
15
+ // - the key is write-only: stored sealed, opened by the run's reader, and never in any
16
+ // response, not even its last four characters.
17
+ //
18
+ // No database, no network, no paid call: the run's createRun is stubbed, so a run that
19
+ // passes every pre-check stops there. The SQL runs on real Postgres in
20
+ // tests/market_watch_data_db.mjs (INTEGRATION set).
21
+ //
22
+ // Run: node tests/market_watch_settings.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import http from 'node:http';
27
+ import { readFileSync } from 'node:fs';
28
+ import { createRequire } from 'node:module';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const api = require('../src/module-api.js');
32
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
33
+ const settings = require('../modules/market-watch/settings.js');
34
+ const key = require('../modules/market-watch/key.js');
35
+ const run = require('../modules/market-watch/run.js');
36
+ const store = require('../modules/market-watch/store.js');
37
+
38
+ const P = 'https://watch.example.test';
39
+ const OTHER = 'https://other.example.test';
40
+ const KEY = 'sk-ant-api03-the-projects-own-key-QX7Z';
41
+ const NOW = Date.parse('2026-10-29T10:00:00Z');
42
+
43
+ // --- an in-memory pool that answers the module's SQL ----------------------------------
44
+ // Each branch matches ONE statement by a phrase only that statement has; anything else
45
+ // throws, so a new query can never be silently answered by the wrong branch.
46
+ function fakePool() {
47
+ const rows = new Map(); // project → settings row
48
+ const runs = []; // { id, project, status, reason, trigger, created_at, cost_usd, items_total, items_done }
49
+ const items = []; // { project, status }
50
+ const seen = [];
51
+ const row = (p) => rows.get(p);
52
+ const ensure = (p) => {
53
+ if (!rows.has(p)) rows.set(p, { project: p, enabled: false, monthly_cap_usd: '20.00', cadence: 'weekly', anthropic_key_enc: null, anthropic_key_last4: null, anthropic_key_set_at: null, updated_by: null });
54
+ return rows.get(p);
55
+ };
56
+ async function query(sql, params = []) {
57
+ seen.push(sql);
58
+ const s = String(sql);
59
+ const p = params[0];
60
+ if (/INSERT INTO market_watch_settings \(project, enabled,/.test(s)) {
61
+ const existed = rows.has(p);
62
+ const r = ensure(p);
63
+ const pick = (v, cur, dflt) => (v !== null ? v : existed ? cur : dflt);
64
+ r.enabled = pick(params[1], r.enabled, params[5]);
65
+ r.monthly_cap_usd = String(pick(params[2], r.monthly_cap_usd, params[6]));
66
+ r.cadence = pick(params[3], r.cadence, params[7]);
67
+ r.updated_by = params[4];
68
+ return { rows: [{ enabled: r.enabled, monthly_cap_usd: r.monthly_cap_usd, cadence: r.cadence }] };
69
+ }
70
+ if (/INSERT INTO market_watch_settings \(project, anthropic_key_enc,/.test(s)) {
71
+ const r = ensure(p);
72
+ assert.match(params[1], /^v1\./, 'the stored key is a secret-box blob');
73
+ assert.equal(params[2].length, 4);
74
+ Object.assign(r, { anthropic_key_enc: params[1], anthropic_key_last4: params[2], anthropic_key_set_at: 'now', updated_by: params[3] });
75
+ return { rows: [{ project: p }] };
76
+ }
77
+ if (/SET anthropic_key_enc = NULL/.test(s)) {
78
+ const r = row(p);
79
+ if (!r || r.anthropic_key_enc === null) return { rows: [] };
80
+ Object.assign(r, { anthropic_key_enc: null, anthropic_key_last4: null, anthropic_key_set_at: null, updated_by: params[1] });
81
+ return { rows: [{ project: p }] };
82
+ }
83
+ if (/SELECT \(anthropic_key_enc IS NOT NULL\) AS key_set/.test(s)) {
84
+ return { rows: row(p) ? [{ key_set: row(p).anthropic_key_enc !== null }] : [] };
85
+ }
86
+ if (/^SELECT anthropic_key_enc FROM market_watch_settings/.test(s.trim())) {
87
+ return { rows: row(p) ? [{ anthropic_key_enc: row(p).anthropic_key_enc }] : [] };
88
+ }
89
+ if (/^SELECT enabled, monthly_cap_usd, cadence(, updated_by, updated_at)?\s+FROM market_watch_settings/.test(s.trim())) {
90
+ return { rows: row(p) ? [{ ...row(p) }] : [] };
91
+ }
92
+ if (/COALESCE\(sum\(ri\.cost_usd\), 0\) AS spent/.test(s)) {
93
+ const spent = runs.filter((r) => r.project === p).reduce((n, r) => n + r.cost_usd, 0);
94
+ return { rows: [{ spent: String(spent) }] };
95
+ }
96
+ if (/AS items_done/.test(s)) {
97
+ return { rows: runs.filter((r) => r.project === p && r.trigger !== 'seed').slice().reverse().slice(0, params[1]).map((r) => ({ ...r, cost_usd: String(r.cost_usd) })) };
98
+ }
99
+ if (/count\(\*\)::int AS n FROM market_watch_items/.test(s)) {
100
+ return { rows: [{ n: items.filter((i) => i.project === p && i.status === 'active').length }] };
101
+ }
102
+ if (/^SELECT created_at FROM market_watch_runs/.test(s.trim())) {
103
+ const mine = runs.filter((r) => r.project === p);
104
+ return { rows: mine.length ? [{ created_at: mine[mine.length - 1].created_at }] : [] };
105
+ }
106
+ throw new Error(`fake pool: unexpected SQL ${s.slice(0, 80)}`);
107
+ }
108
+ return { query, rows, runs, items, seen };
109
+ }
110
+
111
+ // The secret box, faked so a test can see that the route sealed the key and the run's
112
+ // reader opened it. A route must never decrypt: the box counts every decrypt.
113
+ let decrypts = 0;
114
+ let configured = true;
115
+ const box = {
116
+ isConfigured: () => configured,
117
+ encrypt: (s) => `v1.iv.tag.${Buffer.from(s).toString('base64url')}`,
118
+ decrypt: (b) => { decrypts += 1; return Buffer.from(b.split('.')[3], 'base64url').toString(); },
119
+ };
120
+
121
+ // --- the app: real envelope, the route's owner seat, a stubbed session and atom --------
122
+ let viewer = null;
123
+ Object.defineProperty(api, 'requireBuilder', {
124
+ configurable: true,
125
+ value: (req, res, next) => {
126
+ if (!viewer) return res.fail('unauthenticated', 401);
127
+ req.builder = viewer;
128
+ next();
129
+ },
130
+ });
131
+ // The atom gate, at the catalog floor it names: read from catalog.js, not retyped here.
132
+ const catalog = require('../modules/government/catalog.js');
133
+ const atomsAsked = [];
134
+ Object.defineProperty(api, 'requirePermission', {
135
+ configurable: true,
136
+ value: (...atoms) => {
137
+ atomsAsked.push(...atoms);
138
+ return (req, res, next) => {
139
+ const ok = atoms.every((a) => {
140
+ const p = catalog.PERMISSIONS.find((x) => x.key === a);
141
+ return p && api.rankMeetsThreshold(req.builder && req.builder.rank, [p.floor]);
142
+ });
143
+ return ok ? next() : res.fail('permission_forbidden', 403);
144
+ };
145
+ },
146
+ });
147
+ const quiet = { info() {}, warn() {}, error() {}, debug() {}, child() { return quiet; } };
148
+ Object.defineProperty(api, 'logger', { configurable: true, value: () => quiet });
149
+
150
+ const pool = fakePool();
151
+ const express = require('express');
152
+ const app = express();
153
+ app.use(attachFail);
154
+ app.use(express.json());
155
+ const buildRouter = require('../modules/market-watch/routes/settings.js');
156
+ app.use('/api/bongos', buildRouter({ pool, project: P, secretBox: box, now: () => NOW, ceilingUsd: 0.27 }));
157
+ const server = app.listen(0);
158
+ await new Promise((r) => server.once('listening', r));
159
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
160
+ test.after(() => server.close());
161
+
162
+ function call(method, path, body) {
163
+ return new Promise((resolve, reject) => {
164
+ const data = body === undefined ? null : JSON.stringify(body);
165
+ const req = http.request(`${base}${path}`, { method, headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {
166
+ const chunks = [];
167
+ res.on('data', (c) => chunks.push(c));
168
+ res.on('end', () => { const raw = Buffer.concat(chunks).toString(); let parsed = null; try { parsed = JSON.parse(raw); } catch (_) { /* not json */ } resolve({ status: res.statusCode, raw, body: parsed }); });
169
+ });
170
+ req.on('error', reject);
171
+ if (data) req.write(data);
172
+ req.end();
173
+ });
174
+ }
175
+
176
+ const OWNER = { id: 1, rank: 'archon' };
177
+ const METIC = { id: 2, rank: 'metic' };
178
+ const XENOS = { id: 3, rank: 'xenos' };
179
+ const as = (who) => { viewer = who; };
180
+
181
+ // The run, with its own gate: settings through store.js over the SAME pool, the key
182
+ // through key.js readProjectKey. Everything past the pre-checks is stubbed, so a run that
183
+ // would start is recorded as `started` and nothing is ever researched or paid for.
184
+ async function nextTick() {
185
+ const real = store.createStore(pool);
186
+ const started = [];
187
+ const skipped = [];
188
+ const r = await run.runDue({
189
+ project: P,
190
+ now: () => NOW,
191
+ store: {
192
+ ...real,
193
+ openRun: async () => null,
194
+ lastRun: async () => null,
195
+ monthSpend: async () => 0,
196
+ activeRules: async () => ({ id: 1 }),
197
+ insertSkipped: async (project, o) => { skipped.push(o.reason); return 900 + skipped.length; },
198
+ createRun: async () => { started.push(true); return null; },
199
+ },
200
+ readKey: (project) => key.readProjectKey(pool, project, { decrypt: box.decrypt }),
201
+ ceilingUsd: 0.27,
202
+ createClient: () => { throw new Error('no client may be built in this test'); },
203
+ warn: () => {},
204
+ });
205
+ return { r, started: started.length > 0, skipped };
206
+ }
207
+
208
+ const ROUTES = [
209
+ ['GET', '/market-watch/settings', undefined],
210
+ ['PATCH', '/market-watch/settings', { enabled: false }],
211
+ ['PUT', '/market-watch/settings/key', { key: 'sk-ant-api03-somebody-elses-key-0000' }],
212
+ ['DELETE', '/market-watch/settings/key', undefined],
213
+ ];
214
+
215
+ // ---------------------------------------------------------------------------------------
216
+
217
+ test('no session: every route answers 401', async () => {
218
+ as(null);
219
+ for (const [m, p, b] of ROUTES) assert.equal((await call(m, p, b)).status, 401, `${m} ${p}`);
220
+ });
221
+
222
+ test('the owner turns it on, sets the cap and the cadence, and pastes the key', async () => {
223
+ as(OWNER);
224
+ const first = await call('GET', '/market-watch/settings');
225
+ assert.equal(first.status, 200);
226
+ assert.equal(first.body.enabled, false, 'off until the owner turns it on');
227
+ assert.equal(first.body.key_set, false);
228
+ const on = await call('PATCH', '/market-watch/settings', { enabled: true, monthly_cap_usd: 30, cadence: 'biweekly' });
229
+ assert.equal(on.status, 200, on.raw);
230
+ assert.deepEqual([on.body.enabled, on.body.monthly_cap_usd, on.body.cadence], [true, 30, 'biweekly']);
231
+ const k = await call('PUT', '/market-watch/settings/key', { key: KEY });
232
+ assert.equal(k.status, 200, k.raw);
233
+ assert.equal(k.body.key_set, true);
234
+ assert.equal(pool.rows.get(P).updated_by, OWNER.id);
235
+ // A one-field change keeps the others.
236
+ const cap = await call('PATCH', '/market-watch/settings', { monthly_cap_usd: 25 });
237
+ assert.deepEqual([cap.body.enabled, cap.body.monthly_cap_usd, cap.body.cadence], [true, 25, 'biweekly']);
238
+ });
239
+
240
+ test('a non-owner gets 403 from every route, and nothing changes', async () => {
241
+ const before = JSON.stringify(pool.rows.get(P));
242
+ // A Xenos lacks the atom; a Metic holds it and is refused by the owner seat.
243
+ for (const [who, code] of [[METIC, 'rank_forbidden'], [XENOS, 'permission_forbidden']]) {
244
+ as(who);
245
+ for (const [m, p, b] of ROUTES) {
246
+ const res = await call(m, p, b);
247
+ assert.equal(res.status, 403, `${who.rank} ${m} ${p}: ${res.raw}`);
248
+ assert.equal(res.body.error.code, code, `${who.rank} ${m} ${p}`);
249
+ }
250
+ }
251
+ assert.deepEqual([...new Set(atomsAsked)], ['project.settings.manage']);
252
+ assert.equal(JSON.stringify(pool.rows.get(P)), before, 'a refused write left the settings and the key as they were');
253
+ });
254
+
255
+ test('with the watch on and a key set, the next run starts', async () => {
256
+ const { r, started, skipped } = await nextTick();
257
+ assert.equal(started, true, JSON.stringify(r));
258
+ assert.deepEqual(skipped, []);
259
+ });
260
+
261
+ test('a non-owner cannot turn it off: the run still starts', async () => {
262
+ as(METIC);
263
+ assert.equal((await call('PATCH', '/market-watch/settings', { enabled: false })).status, 403);
264
+ const { started } = await nextTick();
265
+ assert.equal(started, true);
266
+ });
267
+
268
+ test('turning Market Watch off stops the next run (the run records it skipped: off)', async () => {
269
+ as(OWNER);
270
+ const off = await call('PATCH', '/market-watch/settings', { enabled: false });
271
+ assert.equal(off.status, 200);
272
+ assert.equal(off.body.enabled, false);
273
+ assert.deepEqual(off.body.next_run, { state: 'paused', due_at: null });
274
+ const { r, started, skipped } = await nextTick();
275
+ assert.equal(started, false, 'no run started');
276
+ assert.deepEqual([r.outcome, r.reason], ['skipped', 'off']);
277
+ assert.deepEqual(skipped, ['off']);
278
+ // On again, and it runs again.
279
+ await call('PATCH', '/market-watch/settings', { enabled: true });
280
+ assert.equal((await nextTick()).started, true);
281
+ });
282
+
283
+ test('the key is stored sealed, opened by the run\'s reader, and never sent back', async () => {
284
+ as(OWNER);
285
+ decrypts = 0;
286
+ const blob = pool.rows.get(P).anthropic_key_enc;
287
+ assert.ok(blob.startsWith('v1.') && !blob.includes(KEY), 'sealed, not plaintext');
288
+ for (const [m, p, b] of [['GET', '/market-watch/settings'], ['PATCH', '/market-watch/settings', { cadence: 'weekly' }], ['PUT', '/market-watch/settings/key', { key: KEY }]]) {
289
+ const res = await call(m, p, b);
290
+ assert.equal(res.status, 200);
291
+ assert.ok(!res.raw.includes(KEY), `${m} ${p} returned the key`);
292
+ assert.ok(!res.raw.includes(KEY.slice(-4)), `${m} ${p} returned the key's last four`);
293
+ assert.ok(!res.raw.includes('anthropic_key'), `${m} ${p} named a key column`);
294
+ }
295
+ assert.equal(decrypts, 0, 'no route decrypts the key');
296
+ assert.equal(await key.readProjectKey(pool, P, { decrypt: box.decrypt }), KEY, 'the run reads exactly what the owner pasted');
297
+ });
298
+
299
+ test('clearing the key stops the next run with no_key; replacing it brings it back', async () => {
300
+ as(OWNER);
301
+ const cleared = await call('DELETE', '/market-watch/settings/key');
302
+ assert.equal(cleared.status, 200);
303
+ assert.equal(cleared.body.key_set, false);
304
+ assert.equal(await key.readProjectKey(pool, P, { decrypt: box.decrypt }), null);
305
+ const { r, started } = await nextTick();
306
+ assert.equal(started, false);
307
+ assert.equal(r.reason, 'no_key');
308
+ const replaced = await call('PUT', '/market-watch/settings/key', { key: `${KEY}-2` });
309
+ assert.equal(replaced.body.key_set, true);
310
+ assert.equal(await key.readProjectKey(pool, P, { decrypt: box.decrypt }), `${KEY}-2`);
311
+ assert.equal((await nextTick()).started, true);
312
+ });
313
+
314
+ test('bad input is refused with 400 and changes nothing', async () => {
315
+ as(OWNER);
316
+ const before = JSON.stringify(pool.rows.get(P));
317
+ for (const body of [{}, { enabled: 'yes' }, { monthly_cap_usd: 0 }, { monthly_cap_usd: 5000 }, { monthly_cap_usd: '20' }, { cadence: 'daily' }, { enabled: true, colour: 'red' }]) {
318
+ const res = await call('PATCH', '/market-watch/settings', body);
319
+ assert.equal(res.status, 400, JSON.stringify(body));
320
+ }
321
+ for (const body of [{}, { key: 'short' }, { key: 'sk-ant-api03 with spaces in it' }, { key: 42 }]) {
322
+ const res = await call('PUT', '/market-watch/settings/key', body);
323
+ assert.equal(res.status, 400, JSON.stringify(body));
324
+ assert.ok(!res.raw.includes('with spaces'), 'a refused key is not echoed');
325
+ }
326
+ assert.equal(JSON.stringify(pool.rows.get(P)), before);
327
+ });
328
+
329
+ test('no key storage on the project: the key is refused with 409, never stored in the clear', async () => {
330
+ as(OWNER);
331
+ configured = false;
332
+ try {
333
+ const res = await call('PUT', '/market-watch/settings/key', { key: KEY });
334
+ assert.equal(res.status, 409);
335
+ assert.equal(res.body.error.code, 'storage_not_configured');
336
+ } finally { configured = true; }
337
+ });
338
+
339
+ test('another project\'s settings are never read or written', async () => {
340
+ assert.equal(pool.rows.has(OTHER), false);
341
+ for (const sql of pool.seen) assert.ok(!sql.includes(OTHER));
342
+ });
343
+
344
+ test('the view: spend, runs of fuel left, the next run, and recent runs with skipped ones marked', async () => {
345
+ as(OWNER);
346
+ const day = (d) => new Date(Date.parse('2026-10-01T09:00:00Z') + d * 86_400_000).toISOString();
347
+ pool.runs.push(
348
+ { id: 1, project: P, status: 'done', reason: null, trigger: 'seed', created_at: day(0), cost_usd: 0, items_total: 0, items_done: 0 },
349
+ { id: 2, project: P, status: 'done', reason: null, trigger: 'poller', created_at: day(7), cost_usd: 3, items_total: 33, items_done: 33 },
350
+ { id: 3, project: P, status: 'skipped', reason: 'cap_reached', trigger: 'poller', created_at: day(14), cost_usd: 0, items_total: 0, items_done: 0 },
351
+ { id: 4, project: P, status: 'done', reason: null, trigger: 'poller', created_at: day(23), cost_usd: 3.2, items_total: 35, items_done: 35 },
352
+ );
353
+ await call('PATCH', '/market-watch/settings', { enabled: true, monthly_cap_usd: 20, cadence: 'weekly' });
354
+ const v = (await call('GET', '/market-watch/settings')).body;
355
+ assert.equal(v.spent_this_month_usd, 6.2);
356
+ assert.equal(v.left_this_month_usd, 13.8);
357
+ assert.equal(v.month, 'october');
358
+ assert.equal(v.run_estimate_usd, 3.1);
359
+ assert.equal(v.run_estimate_basis, 'recent');
360
+ assert.equal(v.runs_left, 4);
361
+ assert.deepEqual(v.next_run, { state: 'scheduled', due_at: new Date(Date.parse(day(23)) + 7 * 86_400_000).toISOString() });
362
+ assert.deepEqual(v.recent_runs.map((r) => [r.id, r.status, r.reason]), [[4, 'done', null], [3, 'skipped', 'cap_reached'], [2, 'done', null]], 'newest first, the seed run hidden');
363
+ assert.deepEqual(v.cadences, ['weekly', 'biweekly', 'monthly']);
364
+ });
365
+
366
+ // --- pure ---------------------------------------------------------------------------
367
+
368
+ test('runEstimate: recent runs first, else every active item at its ceiling, else nothing', () => {
369
+ assert.deepEqual(settings.runEstimate([{ status: 'done', cost_usd: 2 }, { status: 'skipped', cost_usd: 0 }, { status: 'partial', cost_usd: 4 }], 10, 0.27), { usd: 3, basis: 'recent' });
370
+ assert.deepEqual(settings.runEstimate([], 10, 0.27), { usd: 2.7, basis: 'ceiling' });
371
+ assert.deepEqual(settings.runEstimate([], 0, 0.27), { usd: null, basis: 'none' });
372
+ });
373
+
374
+ test('nextRun: paused when off, soon when due, else the date', () => {
375
+ const now = Date.parse('2026-10-29T00:00:00Z');
376
+ assert.deepEqual(settings.nextRun(false, '2026-10-28T00:00:00Z', 'weekly', now), { state: 'paused', due_at: null });
377
+ assert.deepEqual(settings.nextRun(true, null, 'weekly', now), { state: 'soon', due_at: null });
378
+ assert.deepEqual(settings.nextRun(true, '2026-10-01T00:00:00Z', 'weekly', now), { state: 'soon', due_at: null });
379
+ assert.equal(settings.nextRun(true, '2026-10-28T00:00:00Z', 'biweekly', now).due_at, '2026-11-11T00:00:00.000Z');
380
+ });
381
+
382
+ // --- the wall is on every route, not just the ones above -------------------------------
383
+
384
+ test('every route in the settings router mounts requireBuilder, the atom, then the owner seat', () => {
385
+ const src = readFileSync(new URL('../modules/market-watch/routes/settings.js', import.meta.url), 'utf8');
386
+ const routes = [...src.matchAll(/router\.(get|post|put|patch|delete)\(\s*'([^']+)'\s*,([^\n]*)/g)];
387
+ assert.equal(routes.length, ROUTES.length, 'a new route joins ROUTES above, so its 403 is tested');
388
+ for (const [, m, p, rest] of routes) {
389
+ assert.match(rest, /^\s*api\.requireBuilder,\s*api\.requirePermission\('project\.settings\.manage'\),\s*ownerSeat,/, `${m.toUpperCase()} ${p}`);
390
+ assert.ok(ROUTES.some(([rm, rp]) => rm === m.toUpperCase() && rp === p), `${m.toUpperCase()} ${p} is in ROUTES`);
391
+ }
392
+ });
@@ -133,8 +133,8 @@ test('the same query through both: a sort and a maximum price give the same modu
133
133
  for (const [args, query] of [
134
134
  [['--sort', 'tests'], { sort: 'tests' }],
135
135
  [['--max-cents', '500'], { max_cents: '500' }],
136
- [['--max-credits', '0'], { max_credits: '0' }],
137
- [['--sort', 'docs', '--max-cents', '1000', '--max-credits', '100'], { sort: 'docs', max_cents: '1000', max_credits: '100' }],
136
+ [['--max-cents', '0'], { max_cents: '0' }],
137
+ [['--sort', 'docs', '--max-cents', '1000'], { sort: 'docs', max_cents: '1000' }],
138
138
  ]) {
139
139
  const api = (await apiGet(query)).body;
140
140
  const cli = await cliJson(args);
@@ -144,6 +144,15 @@ test('the same query through both: a sort and a maximum price give the same modu
144
144
  }
145
145
  });
146
146
 
147
+ test('there is no maximum in credits, in the CLI or the API (task 1004989)', async () => {
148
+ seed();
149
+ const errs = [];
150
+ const never = async () => { throw new Error('must not be called'); };
151
+ assert.equal(await mod.cmdStore(['--max-credits', '100'], { log: () => {}, errlog: (l) => errs.push(l), request: never }), 2);
152
+ assert.match(errs.join('\n'), /credits do not price modules/);
153
+ assert.equal((await apiGet({ max_credits: '100' })).statusCode, 400);
154
+ });
155
+
147
156
  test('the text view keeps the API order: listed ranked, then New, and says the floor and the cap', async () => {
148
157
  seed();
149
158
  const api = (await apiGet({})).body;
@@ -158,7 +167,9 @@ test('the text view keeps the API order: listed ranked, then New, and says the f
158
167
  assert.equal(at('stripe-10'), -1, 'the eleventh is not shown');
159
168
  assert.match(text, /stripe-00@1\.0\.0 {2}99\/100 {2}free {2}Money › Stripe payments/);
160
169
  assert.match(text, /stripe-01@1\.0\.0 {2}98\/100 {2}\$9\.00/);
161
- assert.match(text, /loose@1\.0\.0 {2}88\/100 {2}40 credits \(not yet buyable\)$/m, 'no category, no place; paid is not yet buyable (task 1004894)');
170
+ assert.match(text, /loose@1\.0\.0 {2}88\/100 {2}price coming \(not yet buyable\)$/m,
171
+ 'an old credit-only price shows as price coming, never as credits (task 1004989); no category, no place');
172
+ assert.doesNotMatch(text, /\d credits/, 'no credit price anywhere in the text view');
162
173
  assert.match(text, /waiting@1\.0\.0 {2}60\/100 {2}free {2}Money$/m, 'a proposed sub-category shows its parent only');
163
174
  assert.match(text, /New — not ranked[\s\S]*fresh@1\.0\.0/);
164
175
  });
@@ -347,11 +347,27 @@ test('the criterion: two modules with the same overall score read differently',
347
347
  // the ranking: quality first, price breaks ties, optional max price (task 1003808)
348
348
  // ---------------------------------------------------------------------------
349
349
 
350
- test('priceOf: no price row is free; 0 in either currency is free; null is not offered', () => {
351
- assert.deepEqual(priceOf(null), { cents: null, credits: null, free: true });
352
- assert.deepEqual(priceOf({ price_cents: 0, price_credits: null }), { cents: 0, credits: null, free: true });
353
- assert.deepEqual(priceOf({ price_cents: 500, price_credits: 0 }), { cents: 500, credits: 0, free: true });
354
- assert.deepEqual(priceOf({ price_cents: 500, price_credits: null }), { cents: 500, credits: null, free: false });
350
+ test('priceOf: no price row is free; a cash price of 0 is free; never a credit amount', () => {
351
+ assert.deepEqual(priceOf(null), { cents: null, free: true, coming: false });
352
+ assert.deepEqual(priceOf({ price_cents: 0, price_credits: null }), { cents: 0, free: true, coming: false });
353
+ assert.deepEqual(priceOf({ price_cents: 500, price_credits: null }), { cents: 500, free: false, coming: false });
354
+ });
355
+
356
+ // Task 1004989 (ADR 0385 decision 2; owner, 2026-10-29): OLD rows may carry a
357
+ // credit price. They are read, never shown as a credit price, and never buyable
358
+ // with credits.
359
+ test('priceOf on old credit rows: credit-only is "price coming", 0 credits stays free, cash wins', () => {
360
+ assert.deepEqual(priceOf({ price_cents: null, price_credits: 40 }), { cents: null, free: false, coming: true },
361
+ 'a module priced only in credits is still listed, with its cash price coming');
362
+ assert.deepEqual(priceOf({ price_cents: null, price_credits: 0 }), { cents: null, free: true, coming: false },
363
+ 'an old 0-credit offer stays free: no credit moves to have it');
364
+ assert.deepEqual(priceOf({ price_cents: 500, price_credits: 0 }), { cents: 500, free: true, coming: false },
365
+ 'an old row offered at 0 credits stays free, as it was');
366
+ assert.deepEqual(priceOf({ price_cents: 500, price_credits: 40 }), { cents: 500, free: false, coming: false },
367
+ 'with a cash price the credit amount is ignored');
368
+ for (const row of [{ price_credits: 40 }, { price_cents: 500, price_credits: 40 }]) {
369
+ assert.equal('credits' in priceOf(row), false, 'no credit amount leaves priceOf');
370
+ }
355
371
  });
356
372
 
357
373
  test('rankListed: a higher score always wins, whatever it costs — price never buys a place', () => {
@@ -362,7 +378,7 @@ test('rankListed: a higher score always wins, whatever it costs — price never
362
378
  assert.deepEqual(ranked.map((e) => e.key), ['dear', 'cheap']);
363
379
  });
364
380
 
365
- test('rankListed: between equal scores, free first, then lowest cash, then lowest credits, then key', () => {
381
+ test('rankListed: between equal scores, free first, then lowest cash, a price still coming last, then key', () => {
366
382
  const input = [
367
383
  { key: 'z-credits', overall: 80, price: priceOf({ price_credits: 5 }) },
368
384
  { key: 'cents-900', overall: 80, price: priceOf({ price_cents: 900 }) },
@@ -374,14 +390,14 @@ test('rankListed: between equal scores, free first, then lowest cash, then lowes
374
390
  assert.equal(input[0].key, 'z-credits', 'the input is untouched');
375
391
  });
376
392
 
377
- test('withinMaxPrice: free always passes; a priced module must be offered at or under a cap the buyer set', () => {
393
+ test('withinMaxPrice: free always passes; a priced module must cost at or under the cash cap', () => {
378
394
  const p = priceOf({ price_cents: 500, price_credits: 7 });
379
395
  assert.equal(withinMaxPrice(p, null), true, 'no cap, no filter');
380
- assert.equal(withinMaxPrice(p, { cents: 500, credits: null }), true, 'at the cap is in');
381
- assert.equal(withinMaxPrice(p, { cents: 499, credits: null }), false);
382
- assert.equal(withinMaxPrice(p, { cents: 1, credits: 7 }), true, 'either currency under its cap is enough');
383
- assert.equal(withinMaxPrice(priceOf({ price_cents: 500 }), { cents: null, credits: 100 }), false, 'not offered in credits, so a credit cap cannot let it in');
384
- assert.equal(withinMaxPrice(priceOf(null), { cents: 0, credits: null }), true);
396
+ assert.equal(withinMaxPrice(p, { cents: 500 }), true, 'at the cap is in');
397
+ assert.equal(withinMaxPrice(p, { cents: 499 }), false);
398
+ assert.equal(withinMaxPrice(p, { cents: 1, credits: 7 }), false, 'a credit cap is ignored: credits never price a module');
399
+ assert.equal(withinMaxPrice(priceOf({ price_credits: 7 }), { cents: 100000 }), false, 'a price still coming has no cash price to compare');
400
+ assert.equal(withinMaxPrice(priceOf(null), { cents: 0 }), true);
385
401
  });
386
402
 
387
403
  test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out what costs more', async () => {
@@ -416,7 +432,7 @@ test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out
416
432
  assert.equal(all.body.max_price, null);
417
433
 
418
434
  const capped = await invoke(handle, { query: { max_cents: '1000' } });
419
- assert.deepEqual(capped.body.max_price, { cents: 1000, credits: null });
435
+ assert.deepEqual(capped.body.max_price, { cents: 1000 });
420
436
  assert.deepEqual(capped.body.listed.map((m) => m.key), ['tie-free', 'tie-paid']);
421
437
  assert.deepEqual(capped.body.new, [], 'the cap applies to New modules too');
422
438
 
@@ -427,13 +443,48 @@ test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out
427
443
 
428
444
  test('GET /store/modules: a maximum price that is not a whole number of 0 or more is refused', async () => {
429
445
  const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
430
- for (const query of [{ max_cents: '-1' }, { max_cents: '5.5' }, { max_credits: 'ten' }, { max_cents: ['1', '2'] }]) {
446
+ for (const query of [{ max_cents: '-1' }, { max_cents: '5.5' }, { max_cents: ['1', '2'] }]) {
431
447
  const res = await invoke(handle, { query });
432
448
  assert.equal(res.statusCode, 400, JSON.stringify(query));
433
449
  assert.match(res.body.message, /maximum price/);
434
450
  }
435
451
  });
436
452
 
453
+ test('GET /store/modules: there is no maximum in credits (task 1004989)', async () => {
454
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
455
+ for (const query of [{ max_credits: '100' }, { max_credits: 'ten' }, { max_cents: '500', max_credits: '1' }]) {
456
+ const res = await invoke(handle, { query });
457
+ assert.equal(res.statusCode, 400, JSON.stringify(query));
458
+ assert.match(res.body.message, /Credits do not price modules/);
459
+ }
460
+ });
461
+
462
+ test('GET /store/modules: an old credit-only price is listed as "price coming", never as credits, and not buyable', async () => {
463
+ state.modules = { legacy: { status: 'listed', title: 'Legacy' }, cash: { status: 'listed', title: 'Cash' } };
464
+ state.versions = [
465
+ { id: 41, module_key: 'legacy', version: '1.0.0', published_at: 't' },
466
+ { id: 42, module_key: 'cash', version: '1.0.0', published_at: 't' },
467
+ ];
468
+ state.scores = [
469
+ { id: 41, version_id: 41, computed_at: 1, ...score({ overall: 80 }) },
470
+ { id: 42, version_id: 42, computed_at: 1, ...score({ overall: 80 }) },
471
+ ];
472
+ state.signals = [];
473
+ state.prices = [
474
+ { module_key: 'legacy', price_cents: null, price_credits: 40, set_at: 1 },
475
+ { module_key: 'cash', price_cents: 900, price_credits: null, set_at: 1 },
476
+ ];
477
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
478
+ const res = await invoke(handle, { query: {} });
479
+ const legacy = res.body.listed.find((m) => m.key === 'legacy');
480
+ assert.ok(legacy, 'a credit-priced module stays visible (owner, 2026-10-29)');
481
+ assert.deepEqual(legacy.price, { cents: null, free: false, coming: true });
482
+ assert.equal(legacy.buyable, false, 'credits never buy it');
483
+ assert.doesNotMatch(JSON.stringify(res.body), /credits/, 'no credit price appears anywhere in the catalog');
484
+ assert.deepEqual(res.body.listed.map((m) => m.key), ['cash', 'legacy'], 'between equal scores a price still coming sorts after a cash price');
485
+ state.prices = [];
486
+ });
487
+
437
488
  // ---------------------------------------------------------------------------
438
489
  // why a module is or is not listed (task 1003810)
439
490
  // ---------------------------------------------------------------------------