@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.
- package/.bongos-core.json +131 -86
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +1 -1
- package/docs/api/openapi.json +185 -6
- package/docs/api-reference.md +7 -3
- package/docs/architecture.md +2 -2
- package/docs/copy-inventory.md +58 -28
- package/docs/copy-registry.json +333 -33
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +3 -3
- package/docs/page-inventory.json +34 -4
- package/docs/page-readings.json +294 -237
- package/migrations/core_287_module_prices_cash_only.sql +24 -0
- package/modules/hall-ui/public/ai-studio.html +2 -2
- package/modules/hall-ui/public/atlas.html +2 -2
- package/modules/hall-ui/public/blockers.html +2 -2
- package/modules/hall-ui/public/board-room.html +2 -2
- package/modules/hall-ui/public/city.html +2 -2
- package/modules/hall-ui/public/collab.html +2 -2
- package/modules/hall-ui/public/commands.html +2 -2
- package/modules/hall-ui/public/copy-desk.html +2 -2
- package/modules/hall-ui/public/craft-mode.js +1 -1
- package/modules/hall-ui/public/deploy.html +2 -2
- package/modules/hall-ui/public/diagrams.html +2 -2
- package/modules/hall-ui/public/drachmae.html +2 -2
- package/modules/hall-ui/public/fleet.html +2 -2
- package/modules/hall-ui/public/gate.html +2 -2
- package/modules/hall-ui/public/goal-map.html +2 -2
- package/modules/hall-ui/public/goals.html +2 -2
- package/modules/hall-ui/public/government.html +2 -2
- package/modules/hall-ui/public/idea.html +2 -2
- package/modules/hall-ui/public/ideas.html +2 -2
- package/modules/hall-ui/public/index.html +2 -2
- package/modules/hall-ui/public/market-watch-rules.html +2 -2
- package/modules/hall-ui/public/market-watch-settings.css +217 -0
- package/modules/hall-ui/public/market-watch-settings.html +113 -0
- package/modules/hall-ui/public/market-watch-settings.js +230 -0
- package/modules/hall-ui/public/market-watch-settings.states.json +14 -0
- package/modules/hall-ui/public/modules.html +2 -2
- package/modules/hall-ui/public/modules.js +5 -0
- package/modules/hall-ui/public/my-agents.html +2 -2
- package/modules/hall-ui/public/primer.html +2 -2
- package/modules/hall-ui/public/profile.html +2 -2
- package/modules/hall-ui/public/project-settings.html +2 -2
- package/modules/hall-ui/public/ranks.html +2 -2
- package/modules/hall-ui/public/roadmap.html +2 -2
- package/modules/hall-ui/public/roster.html +2 -2
- package/modules/hall-ui/public/sessions.html +2 -2
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/shell.js +3 -0
- package/modules/hall-ui/public/street.html +2 -2
- package/modules/hall-ui/public/studio.html +2 -2
- package/modules/hall-ui/public/task.html +2 -2
- package/modules/hall-ui/public/thinking.html +2 -2
- package/modules/hall-ui/public/tweak-editor.html +2 -2
- package/modules/hall-ui/public/watch.html +2 -2
- package/modules/hall-ui/public/work.html +2 -2
- package/modules/hall-ui/records/market-watch-settings.md +11 -0
- package/modules/market-watch/module.json +3 -2
- package/modules/market-watch/routes/settings.js +128 -0
- package/modules/market-watch/settings.js +207 -0
- package/modules/provisioning/starter-bundles.js +11 -6
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/module-store-cli.js +21 -21
- package/scripts/bongos/module.js +8 -8
- package/src/bongos/module-listing.js +47 -20
- package/src/bongos/module-scope-map.js +1 -0
- package/src/bongos/module-store.js +20 -12
- package/src/bongos/routes/modules.js +31 -21
- package/src/bongos/serve-internal.js +2 -2
- package/src/module-api.js +1 -1
- package/tests/hall_nav.mjs +4 -2
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/market_watch_data_db.mjs +27 -0
- package/tests/market_watch_module.mjs +8 -4
- package/tests/market_watch_settings.mjs +392 -0
- package/tests/module_store_cli.mjs +14 -3
- package/tests/module_store_listing.mjs +65 -14
- package/tests/module_store_price.mjs +97 -26
- package/tests/module_store_registry_migration.mjs +11 -0
- 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-
|
|
137
|
-
[['--sort', 'docs', '--max-cents', '1000'
|
|
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}
|
|
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;
|
|
351
|
-
assert.deepEqual(priceOf(null), { cents: null,
|
|
352
|
-
assert.deepEqual(priceOf({ price_cents: 0, price_credits: null }), { cents: 0,
|
|
353
|
-
assert.deepEqual(priceOf({ price_cents: 500, price_credits:
|
|
354
|
-
|
|
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,
|
|
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
|
|
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
|
|
381
|
-
assert.equal(withinMaxPrice(p, { cents: 499
|
|
382
|
-
assert.equal(withinMaxPrice(p, { cents: 1, credits: 7 }),
|
|
383
|
-
assert.equal(withinMaxPrice(priceOf({
|
|
384
|
-
assert.equal(withinMaxPrice(priceOf(null), { cents: 0
|
|
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
|
|
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' }, {
|
|
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
|
// ---------------------------------------------------------------------------
|