@bongos/core 1.24.9 → 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 +113 -73
- 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 +10 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +178 -2
- package/docs/api-reference.md +6 -2
- package/docs/copy-inventory.md +47 -19
- package/docs/copy-registry.json +306 -24
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +34 -4
- package/docs/page-readings.json +296 -239
- 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/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 +6 -0
- package/src/bongos/module-scope-map.js +1 -0
- 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/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
|
+
});
|
|
@@ -92,8 +92,9 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
92
92
|
// the seats read from /me's server-resolved positions block; task 1004730: the banner
|
|
93
93
|
// says a held core version was tried and rolled back; task 1004731: the one-time
|
|
94
94
|
// "Upgraded to core X" notice after a move; task 1004826: Standing is Accomplishments;
|
|
95
|
-
// task 1004846: the new logo (its bump landed late, in task 1004852)
|
|
96
|
-
|
|
95
|
+
// task 1004846: the new logo (its bump landed late, in task 1004852); task 1004938: the
|
|
96
|
+
// Market Watch settings nav item and its fuel glyph.
|
|
97
|
+
const SHELL_SHA = '4022e01fbb4045ad';
|
|
97
98
|
|
|
98
99
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
99
100
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|