@bongos/core 1.25.11 → 1.25.12
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 +82 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +108 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +195 -3
- package/docs/api-reference.md +10 -1
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/front-door-guide/CLAUDE.md +15 -0
- package/modules/front-door-guide/claude.js +51 -0
- package/modules/front-door-guide/db.js +72 -0
- package/modules/front-door-guide/guide.js +139 -0
- package/modules/front-door-guide/limits.js +87 -0
- package/modules/front-door-guide/migrations/front_door_guide_001_conversations.sql +48 -0
- package/modules/front-door-guide/module.json +27 -0
- package/modules/front-door-guide/routes/front-door-guide.js +206 -0
- package/modules/front-door-guide/settings.js +68 -0
- package/modules/provisioning/starter-bundles.js +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/bongos/publish-manifest.js +1 -0
- package/src/bongos/module-scope-map.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/front_door_guide.mjs +410 -0
- package/tests/module_loader.mjs +1 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// tests/front_door_guide.mjs — the start-a-project guide's server (task 1005272): a
|
|
2
|
+
// signed-out visitor starts a chat, talks, and every wall holds — the message cap, the
|
|
3
|
+
// length limit, the per-visitor spend cap, the daily and monthly budget cut-off to the
|
|
4
|
+
// form, the missing-key fallback, a refused reply, and a scripted abuse run.
|
|
5
|
+
//
|
|
6
|
+
// Drives the REAL router over an in-memory db and a mocked Claude client, so it needs no
|
|
7
|
+
// Postgres, no network and no key, and spends no money. Settings come from the
|
|
8
|
+
// environment, re-read per request, so each test sets the knobs it is about.
|
|
9
|
+
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const db = require('../modules/front-door-guide/db.js');
|
|
17
|
+
const claude = require('../modules/front-door-guide/claude.js');
|
|
18
|
+
const G = require('../modules/front-door-guide/guide.js');
|
|
19
|
+
|
|
20
|
+
const KEY = 'sk-ant-api03-TEST-ONLY-NOT-A-REAL-KEY-0123456789';
|
|
21
|
+
const ENV = 'BONGOS_FRONT_DOOR_GUIDE_';
|
|
22
|
+
|
|
23
|
+
// ---- an in-memory db with the same rules as the SQL ----------------------------------
|
|
24
|
+
|
|
25
|
+
const convs = new Map();
|
|
26
|
+
let callRows = [];
|
|
27
|
+
let seq = 0;
|
|
28
|
+
let baselineDay = 0; // spend already on the books today, outside these tests
|
|
29
|
+
let baselineMonth = 0;
|
|
30
|
+
const copy = (c) => c && { ...c, messages: JSON.parse(JSON.stringify(c.messages)) };
|
|
31
|
+
Object.assign(db, {
|
|
32
|
+
createConversation: async (tokenHash, visitorHash) => {
|
|
33
|
+
const c = { id: String(++seq), token_hash: tokenHash, visitor_hash: visitorHash, messages: [], visitor_messages: 0, spend_micro: 0, status: 'open', end_reason: null, created_at: new Date(Date.now() - 10_000).toISOString() };
|
|
34
|
+
convs.set(c.id, c);
|
|
35
|
+
return copy(c);
|
|
36
|
+
},
|
|
37
|
+
getConversation: async (id, tokenHash) => { const c = convs.get(String(id)); return c && c.token_hash === tokenHash ? copy(c) : null; },
|
|
38
|
+
siteSpendMicro: async (period) => callRows.reduce((s, r) => s + r.cost_micro, period === 'day' ? baselineDay : baselineMonth),
|
|
39
|
+
visitorUsage: async (visitorHash) => {
|
|
40
|
+
const mine = callRows.filter((r) => r.visitor_hash === visitorHash);
|
|
41
|
+
return { messages: mine.length, spendMicro: mine.reduce((s, r) => s + r.cost_micro, 0) };
|
|
42
|
+
},
|
|
43
|
+
recordTurn: async (convId, { messages, status, endReason }, call) => {
|
|
44
|
+
callRows.push({ conversation_id: String(convId), visitor_hash: call.visitorHash, cost_micro: call.costMicro, model: call.model, input_tokens: call.inputTokens, output_tokens: call.outputTokens, stop_reason: call.stopReason });
|
|
45
|
+
const c = convs.get(String(convId));
|
|
46
|
+
Object.assign(c, { messages, status, end_reason: endReason, visitor_messages: c.visitor_messages + 1, spend_micro: c.spend_micro + call.costMicro });
|
|
47
|
+
return copy(c);
|
|
48
|
+
},
|
|
49
|
+
endConversation: async (convId, reason) => Object.assign(convs.get(String(convId)), { status: 'ended', end_reason: reason }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---- the mocked Claude client: records every request, answers from a script ----------
|
|
53
|
+
|
|
54
|
+
let requests = [];
|
|
55
|
+
let respond = null;
|
|
56
|
+
let delayMs = 0;
|
|
57
|
+
let clientKeys = [];
|
|
58
|
+
const normalReply = () => ({
|
|
59
|
+
model: 'claude-opus-5-5', stop_reason: 'end_turn',
|
|
60
|
+
content: [{ type: 'thinking', thinking: '' }, { type: 'text', text: 'Sounds useful. Who will use it first?' }],
|
|
61
|
+
usage: { input_tokens: 800, output_tokens: 200 },
|
|
62
|
+
});
|
|
63
|
+
claude.createClient = (apiKey) => {
|
|
64
|
+
clientKeys.push(apiKey);
|
|
65
|
+
return { messages: { create: async (req) => {
|
|
66
|
+
requests.push(req);
|
|
67
|
+
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
|
|
68
|
+
return respond ? respond(req) : normalReply();
|
|
69
|
+
} } };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ---- the real router over http ----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const express = require('express');
|
|
75
|
+
const app = express();
|
|
76
|
+
app.set('trust proxy', true); // so each test can be a different visitor via X-Forwarded-For
|
|
77
|
+
app.use(express.json({ limit: '64kb' }));
|
|
78
|
+
app.use('/api/bongos', require('../modules/front-door-guide/routes/front-door-guide.js')());
|
|
79
|
+
const server = app.listen(0);
|
|
80
|
+
await new Promise((r) => server.once('listening', r));
|
|
81
|
+
const PORT = server.address().port;
|
|
82
|
+
|
|
83
|
+
const UA = 'Mozilla/5.0 (test browser)';
|
|
84
|
+
function call(method, path, { body, ip = '10.0.0.1', ua = UA, headers = {} } = {}) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const data = body === undefined ? null : JSON.stringify(body);
|
|
87
|
+
const h = { 'content-type': 'application/json', 'x-forwarded-for': ip, ...headers };
|
|
88
|
+
if (ua) h['user-agent'] = ua;
|
|
89
|
+
const req = http.request({ host: '127.0.0.1', port: PORT, method, path: `/api/bongos${path}`, headers: h }, (res) => {
|
|
90
|
+
let buf = '';
|
|
91
|
+
res.on('data', (c) => { buf += c; });
|
|
92
|
+
res.on('end', () => resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null, raw: buf }));
|
|
93
|
+
});
|
|
94
|
+
req.on('error', reject);
|
|
95
|
+
if (data) req.write(data);
|
|
96
|
+
req.end();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let ipSeq = 0;
|
|
101
|
+
const freshIp = () => `10.1.${Math.floor(++ipSeq / 250)}.${ipSeq % 250}`;
|
|
102
|
+
|
|
103
|
+
function setEnv(over = {}) {
|
|
104
|
+
for (const k of Object.keys(process.env)) if (k.startsWith(ENV)) delete process.env[k];
|
|
105
|
+
const all = {
|
|
106
|
+
ANTHROPIC_API_KEY: KEY, MIN_FIRST_MESSAGE_MS: '1', STARTS_PER_HOUR: '1000', MESSAGES_PER_MINUTE: '1000',
|
|
107
|
+
...over,
|
|
108
|
+
};
|
|
109
|
+
for (const [k, v] of Object.entries(all)) if (v !== null) process.env[ENV + k] = String(v);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function reset(over) {
|
|
113
|
+
setEnv(over);
|
|
114
|
+
convs.clear(); callRows = []; requests = []; clientKeys = []; respond = null; delayMs = 0;
|
|
115
|
+
baselineDay = 0; baselineMonth = 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function start(ip) {
|
|
119
|
+
const r = await call('POST', '/front-door-guide/conversations', { body: {}, ip });
|
|
120
|
+
return r;
|
|
121
|
+
}
|
|
122
|
+
const say = (conv, text, ip, extra = {}) => call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { body: { token: conv.token, text, ...extra }, ip });
|
|
123
|
+
|
|
124
|
+
test.after(() => server.close());
|
|
125
|
+
|
|
126
|
+
// ---- the conversation itself -----------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
test('a signed-out visitor starts a chat and gets an on-topic reply; the transcript reads back', async () => {
|
|
129
|
+
reset();
|
|
130
|
+
const ip = freshIp();
|
|
131
|
+
const s = await call('GET', '/front-door-guide/status', { ip });
|
|
132
|
+
assert.equal(s.status, 200);
|
|
133
|
+
assert.equal(s.body.mode, 'chat');
|
|
134
|
+
assert.deepEqual(s.body.limits, { max_messages: 8, max_message_chars: 1000 });
|
|
135
|
+
|
|
136
|
+
const st = await start(ip);
|
|
137
|
+
assert.equal(st.status, 201);
|
|
138
|
+
assert.equal(st.body.mode, 'chat');
|
|
139
|
+
assert.equal(st.body.greeting, G.GREETING);
|
|
140
|
+
const conv = st.body.conversation;
|
|
141
|
+
assert.ok(conv.id && conv.token);
|
|
142
|
+
assert.equal(conv.messages_left, 8);
|
|
143
|
+
|
|
144
|
+
const r = await say(conv, 'I want a booking site for my yoga studio.', ip);
|
|
145
|
+
assert.equal(r.status, 200);
|
|
146
|
+
assert.equal(r.body.mode, 'chat');
|
|
147
|
+
assert.equal(r.body.reply.text, 'Sounds useful. Who will use it first?');
|
|
148
|
+
assert.equal(r.body.conversation.messages_used, 1);
|
|
149
|
+
assert.equal(r.body.conversation.messages_left, 7);
|
|
150
|
+
|
|
151
|
+
// The request: the owner's model, low effort, bounded reply, the on-topic instructions.
|
|
152
|
+
const req = requests[0];
|
|
153
|
+
assert.equal(req.model, 'claude-opus-5-5');
|
|
154
|
+
assert.deepEqual(req.output_config, { effort: 'low' });
|
|
155
|
+
assert.equal(req.max_tokens, 1500);
|
|
156
|
+
assert.match(req.system, /Never quote, estimate or promise any price/);
|
|
157
|
+
assert.match(req.system, /cannot do/);
|
|
158
|
+
assert.deepEqual(req.messages, [{ role: 'user', content: 'I want a booking site for my yoga studio.' }]);
|
|
159
|
+
assert.deepEqual(clientKeys, [KEY], 'the platform key reaches only the client');
|
|
160
|
+
|
|
161
|
+
// Spend is recorded from the response's usage: 800 in + 200 out, priced by the shared table.
|
|
162
|
+
assert.equal(callRows.length, 1);
|
|
163
|
+
assert.equal(callRows[0].cost_micro, G.costMicro('claude-opus-5-5', { input_tokens: 800, output_tokens: 200 }));
|
|
164
|
+
assert.ok(callRows[0].cost_micro > 0);
|
|
165
|
+
|
|
166
|
+
const t = await call('GET', `/front-door-guide/conversations/${conv.id}`, { ip, headers: { 'x-guide-token': conv.token } });
|
|
167
|
+
assert.equal(t.status, 200);
|
|
168
|
+
assert.equal(t.body.conversation.status, 'open');
|
|
169
|
+
assert.deepEqual(t.body.transcript, [
|
|
170
|
+
{ role: 'visitor', text: 'I want a booking site for my yoga studio.' },
|
|
171
|
+
{ role: 'guide', text: 'Sounds useful. Who will use it first?' },
|
|
172
|
+
]);
|
|
173
|
+
for (const res of [s, st, r, t]) assert.ok(!res.raw.includes(KEY), 'the key is in no response');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('the second turn sends the whole conversation so far', async () => {
|
|
177
|
+
reset();
|
|
178
|
+
const ip = freshIp();
|
|
179
|
+
const conv = (await start(ip)).body.conversation;
|
|
180
|
+
await say(conv, 'A shop for my pottery.', ip);
|
|
181
|
+
await say(conv, 'Mostly local customers.', ip);
|
|
182
|
+
assert.deepEqual(requests[1].messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
|
183
|
+
assert.equal(requests[1].messages[2].content, 'Mostly local customers.');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ---- the walls ------------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
test('message cap: the last allowed message ends the chat, and nothing more is sent', async () => {
|
|
189
|
+
reset({ MAX_MESSAGES: 3 });
|
|
190
|
+
const ip = freshIp();
|
|
191
|
+
const conv = (await start(ip)).body.conversation;
|
|
192
|
+
assert.equal((await say(conv, 'one', ip)).body.mode, 'chat');
|
|
193
|
+
assert.equal((await say(conv, 'two', ip)).body.mode, 'chat');
|
|
194
|
+
const third = await say(conv, 'three', ip);
|
|
195
|
+
assert.equal(third.body.mode, 'ended');
|
|
196
|
+
assert.equal(third.body.reason, 'message_cap');
|
|
197
|
+
assert.ok(third.body.reply.text, 'the last message still gets its reply');
|
|
198
|
+
assert.equal(third.body.conversation.messages_left, 0);
|
|
199
|
+
|
|
200
|
+
const fourth = await say(conv, 'four', ip);
|
|
201
|
+
assert.equal(fourth.status, 200);
|
|
202
|
+
assert.equal(fourth.body.mode, 'ended');
|
|
203
|
+
assert.equal(requests.length, 3, 'no call past the cap');
|
|
204
|
+
|
|
205
|
+
// A new chat does not reset the visitor's cap.
|
|
206
|
+
const again = await start(ip);
|
|
207
|
+
assert.equal(again.body.mode, 'form');
|
|
208
|
+
assert.equal(again.body.reason, 'visitor_limit');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('length limit: one character over is refused before any call; exactly the limit is fine', async () => {
|
|
212
|
+
reset({ MAX_MESSAGE_CHARS: 40 });
|
|
213
|
+
const ip = freshIp();
|
|
214
|
+
const conv = (await start(ip)).body.conversation;
|
|
215
|
+
const over = await say(conv, 'x'.repeat(41), ip);
|
|
216
|
+
assert.equal(over.status, 400);
|
|
217
|
+
assert.equal(over.body.error, 'too_long');
|
|
218
|
+
assert.equal(over.body.max_message_chars, 40);
|
|
219
|
+
assert.equal(requests.length, 0);
|
|
220
|
+
assert.equal((await say(conv, 'x'.repeat(40), ip)).body.mode, 'chat');
|
|
221
|
+
assert.equal((await say(conv, ' ', ip)).body.error, 'empty');
|
|
222
|
+
assert.equal(requests.length, 1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('per-visitor spend cap: the visitor stops before passing it, and never passes it', async () => {
|
|
226
|
+
reset({ VISITOR_CAP_CENTS: 6 }); // 60,000 micro-dollars
|
|
227
|
+
const ip = freshIp();
|
|
228
|
+
const conv = (await start(ip)).body.conversation;
|
|
229
|
+
let last;
|
|
230
|
+
for (let i = 0; i < 8; i++) {
|
|
231
|
+
last = await say(conv, `message ${i}`, ip);
|
|
232
|
+
if (last.body.mode !== 'chat') break;
|
|
233
|
+
}
|
|
234
|
+
assert.equal(last.body.mode, 'ended');
|
|
235
|
+
assert.equal(last.body.reason, 'visitor_budget');
|
|
236
|
+
const spent = callRows.reduce((s, r) => s + r.cost_micro, 0);
|
|
237
|
+
assert.ok(spent <= 60_000, `spent ${spent} within the cap`);
|
|
238
|
+
assert.ok(callRows.length >= 1 && callRows.length < 8);
|
|
239
|
+
const after = await say(conv, 'one more', ip);
|
|
240
|
+
assert.equal(after.body.mode, 'ended');
|
|
241
|
+
assert.equal(requests.length, callRows.length, 'no call after the cap');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('daily budget cut-off: the guide answers the form, never an error, and makes no call', async () => {
|
|
245
|
+
reset({ DAILY_BUDGET_CENTS: 200 });
|
|
246
|
+
const ip = freshIp();
|
|
247
|
+
const conv = (await start(ip)).body.conversation;
|
|
248
|
+
baselineDay = 2_000_000 - 1_000; // a tenth of a cent left today: less than one reply could cost
|
|
249
|
+
const r = await say(conv, 'Hello there', ip);
|
|
250
|
+
assert.equal(r.status, 200);
|
|
251
|
+
assert.deepEqual(r.body, G.formAnswer('daily_budget'));
|
|
252
|
+
assert.equal(requests.length, 0);
|
|
253
|
+
const s = await call('GET', '/front-door-guide/status', { ip });
|
|
254
|
+
assert.equal(s.body.mode, 'chat', 'status still offers the chat while a cent is left');
|
|
255
|
+
baselineDay = 2_000_000;
|
|
256
|
+
assert.equal((await call('GET', '/front-door-guide/status', { ip })).body.reason, 'daily_budget');
|
|
257
|
+
const st = await start(freshIp());
|
|
258
|
+
assert.equal(st.status, 200);
|
|
259
|
+
assert.equal(st.body.mode, 'form');
|
|
260
|
+
assert.equal(st.body.reason, 'daily_budget');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('monthly budget cut-off: the form too', async () => {
|
|
264
|
+
reset({ MONTHLY_BUDGET_CENTS: 2500 });
|
|
265
|
+
baselineMonth = 25_000_000;
|
|
266
|
+
const st = await start(freshIp());
|
|
267
|
+
assert.deepEqual(st.body, G.formAnswer('monthly_budget'));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('no key configured: every route answers the form, never an error, and no client is made', async () => {
|
|
271
|
+
reset({ ANTHROPIC_API_KEY: null });
|
|
272
|
+
const ip = freshIp();
|
|
273
|
+
const s = await call('GET', '/front-door-guide/status', { ip });
|
|
274
|
+
assert.equal(s.status, 200);
|
|
275
|
+
assert.equal(s.body.mode, 'form');
|
|
276
|
+
assert.equal(s.body.reason, 'no_key');
|
|
277
|
+
const st = await start(ip);
|
|
278
|
+
assert.equal(st.status, 200);
|
|
279
|
+
assert.deepEqual(st.body, G.formAnswer('no_key'));
|
|
280
|
+
|
|
281
|
+
// A chat started while the key was set, then the key removed: the form, not a 500.
|
|
282
|
+
setEnv();
|
|
283
|
+
const conv = (await start(ip)).body.conversation;
|
|
284
|
+
setEnv({ ANTHROPIC_API_KEY: null });
|
|
285
|
+
const r = await say(conv, 'hello', ip);
|
|
286
|
+
assert.equal(r.status, 200);
|
|
287
|
+
assert.equal(r.body.reason, 'no_key');
|
|
288
|
+
assert.equal(clientKeys.length, 0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('a refusal stop reason gets a graceful reply, and the refused words are not sent again', async () => {
|
|
292
|
+
reset();
|
|
293
|
+
const ip = freshIp();
|
|
294
|
+
const conv = (await start(ip)).body.conversation;
|
|
295
|
+
respond = () => ({ model: 'claude-opus-5-5', stop_reason: 'refusal', stop_details: { type: 'refusal', category: null }, content: [], usage: { input_tokens: 500, output_tokens: 3 } });
|
|
296
|
+
const r = await say(conv, 'something off topic', ip);
|
|
297
|
+
assert.equal(r.status, 200);
|
|
298
|
+
assert.equal(r.body.mode, 'chat');
|
|
299
|
+
assert.equal(r.body.declined, true);
|
|
300
|
+
assert.equal(r.body.reply.text, G.REFUSAL_REPLY);
|
|
301
|
+
assert.equal(callRows[0].stop_reason, 'refusal');
|
|
302
|
+
assert.ok(callRows[0].cost_micro > 0, 'a refused call is still recorded');
|
|
303
|
+
|
|
304
|
+
respond = null;
|
|
305
|
+
await say(conv, 'OK, a recipe website.', ip);
|
|
306
|
+
const sent = JSON.stringify(requests[1].messages);
|
|
307
|
+
assert.ok(!sent.includes('something off topic'));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('a failed API call answers the form and records no spend', async () => {
|
|
311
|
+
reset();
|
|
312
|
+
const ip = freshIp();
|
|
313
|
+
const conv = (await start(ip)).body.conversation;
|
|
314
|
+
respond = () => { const e = new Error('overloaded'); e.status = 529; throw e; };
|
|
315
|
+
const r = await say(conv, 'hello', ip);
|
|
316
|
+
assert.equal(r.status, 200);
|
|
317
|
+
assert.deepEqual(r.body, G.formAnswer('unavailable'));
|
|
318
|
+
assert.equal(callRows.length, 0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ---- a scripted abuse run -------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
test('scripted abuse run: bots, stolen ids, floods and a budget stampede all hold', async () => {
|
|
324
|
+
reset({ DAILY_BUDGET_CENTS: 10, STARTS_PER_HOUR: 3, MESSAGES_PER_MINUTE: 4 });
|
|
325
|
+
|
|
326
|
+
// 1. The bot check: honeypot filled, no user agent, a first message faster than typing.
|
|
327
|
+
const botIp = freshIp();
|
|
328
|
+
assert.equal((await call('POST', '/front-door-guide/conversations', { body: { website: 'http://spam' }, ip: botIp })).body.reason, 'check_failed');
|
|
329
|
+
assert.equal((await call('POST', '/front-door-guide/conversations', { body: {}, ip: freshIp(), ua: null })).body.reason, 'check_failed');
|
|
330
|
+
const ip1 = freshIp();
|
|
331
|
+
const conv = (await start(ip1)).body.conversation;
|
|
332
|
+
assert.equal((await say(conv, 'hi', ip1, { website: 'x' })).body.reason, 'check_failed');
|
|
333
|
+
setEnv({ DAILY_BUDGET_CENTS: 10, STARTS_PER_HOUR: 3, MESSAGES_PER_MINUTE: 4, MIN_FIRST_MESSAGE_MS: 60_000 });
|
|
334
|
+
assert.equal((await say(conv, 'instant', ip1)).body.reason, 'check_failed');
|
|
335
|
+
setEnv({ DAILY_BUDGET_CENTS: 10, STARTS_PER_HOUR: 3, MESSAGES_PER_MINUTE: 4 });
|
|
336
|
+
assert.equal(requests.length, 0, 'no bot reached the API');
|
|
337
|
+
|
|
338
|
+
// 2. Someone else's chat: a wrong token or a made-up id is a 404; garbage is a 400.
|
|
339
|
+
assert.equal((await say({ id: conv.id, token: 'A'.repeat(32) }, 'hijack', freshIp())).status, 404);
|
|
340
|
+
assert.equal((await say({ id: '999999', token: conv.token }, 'hijack', freshIp())).status, 404);
|
|
341
|
+
assert.equal((await say({ id: 'abc', token: conv.token }, 'x', freshIp())).status, 400);
|
|
342
|
+
assert.equal((await call('GET', `/front-door-guide/conversations/${conv.id}`, { headers: { 'x-guide-token': 'B'.repeat(32) } })).status, 404);
|
|
343
|
+
|
|
344
|
+
// 3. Firing five messages at once: one call runs, the rest are told to wait.
|
|
345
|
+
delayMs = 60;
|
|
346
|
+
const burst = await Promise.all([0, 1, 2, 3].map((i) => say(conv, `parallel ${i}`, ip1)));
|
|
347
|
+
delayMs = 0;
|
|
348
|
+
const answered = burst.filter((b) => b.status === 200);
|
|
349
|
+
assert.equal(answered.length, 1, 'exactly one parallel message was answered');
|
|
350
|
+
assert.ok(burst.filter((b) => b.status === 429).every((b) => ['busy', 'rate_limited'].includes(b.body.error)));
|
|
351
|
+
assert.equal(requests.length, 1);
|
|
352
|
+
|
|
353
|
+
// 4. A message flood from one address hits the per-minute limit with Retry-After.
|
|
354
|
+
let flooded = null;
|
|
355
|
+
for (let i = 0; i < 6 && !flooded; i++) {
|
|
356
|
+
const r = await say(conv, `flood ${i}`, ip1);
|
|
357
|
+
if (r.status === 429 && r.body.error === 'rate_limited') flooded = r;
|
|
358
|
+
}
|
|
359
|
+
assert.ok(flooded, 'the per-minute limiter tripped');
|
|
360
|
+
assert.ok(flooded.body.retry_after_seconds >= 1);
|
|
361
|
+
|
|
362
|
+
// 5. A start flood from one address hits the per-hour limit.
|
|
363
|
+
const ip2 = freshIp();
|
|
364
|
+
const starts = [];
|
|
365
|
+
for (let i = 0; i < 5; i++) starts.push((await start(ip2)).status);
|
|
366
|
+
assert.deepEqual(starts.slice(0, 3), [201, 201, 201]);
|
|
367
|
+
assert.equal(starts[3], 429);
|
|
368
|
+
|
|
369
|
+
// 6. A stampede of fresh visitors cannot spend past the daily budget: once it is
|
|
370
|
+
// near, everyone gets the form, and the books never pass ten cents.
|
|
371
|
+
let forms = 0;
|
|
372
|
+
for (let v = 0; v < 25; v++) {
|
|
373
|
+
const ip = freshIp();
|
|
374
|
+
const st = await start(ip);
|
|
375
|
+
if (st.body.mode === 'form') { forms++; continue; }
|
|
376
|
+
for (let m = 0; m < 3; m++) {
|
|
377
|
+
const r = await say(st.body.conversation, `visitor ${v} message ${m}`, ip);
|
|
378
|
+
if (r.body.mode === 'form') { forms++; break; }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const spent = callRows.reduce((s, r) => s + r.cost_micro, 0);
|
|
382
|
+
assert.ok(spent <= 100_000, `site spend ${spent} stays within the daily budget`);
|
|
383
|
+
assert.ok(forms > 0, 'the budget cut-off was reached and answered with the form');
|
|
384
|
+
const s = await call('GET', '/front-door-guide/status', { ip: freshIp() });
|
|
385
|
+
assert.equal(s.status, 200);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ---- pure pieces --------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
test('settings: env knobs override, bad values fall back to the defaults, money in micro-dollars', () => {
|
|
391
|
+
const { readSettings, DEFAULTS } = require('../modules/front-door-guide/settings.js');
|
|
392
|
+
const s = readSettings({ resolve: (k) => ({ FRONT_DOOR_GUIDE_MAX_MESSAGES: '3', FRONT_DOOR_GUIDE_DAILY_BUDGET_CENTS: '-5', FRONT_DOOR_GUIDE_MODEL: 'gpt-4', FRONT_DOOR_GUIDE_EFFORT: 'max' })[k] });
|
|
393
|
+
assert.equal(s.maxMessages, 3);
|
|
394
|
+
assert.equal(s.dailyBudgetMicro, DEFAULTS.DAILY_BUDGET_CENTS * 10_000, 'a negative budget is not a budget');
|
|
395
|
+
assert.equal(s.model, 'claude-opus-5-5', 'only a Claude model id is accepted');
|
|
396
|
+
assert.equal(s.effort, 'max');
|
|
397
|
+
assert.equal(s.apiKey, null);
|
|
398
|
+
const d = readSettings({ resolve: () => undefined });
|
|
399
|
+
assert.equal(d.visitorCapMicro, 500_000);
|
|
400
|
+
assert.equal(d.monthlyBudgetMicro, 25_000_000);
|
|
401
|
+
assert.equal(d.maxMessageChars, 1000);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('the reservation covers a full-length reply, so it is never below the recorded cost', () => {
|
|
405
|
+
const system = G.systemPrompt('Cloud Bongos');
|
|
406
|
+
const messages = [{ role: 'user', content: 'x'.repeat(1000) }];
|
|
407
|
+
const est = G.estimateMicro({ model: 'claude-opus-5-5', system, messages, maxReplyTokens: 1500 });
|
|
408
|
+
const real = G.costMicro('claude-opus-5-5', { input_tokens: Math.ceil((system.length + 1000) / 4), output_tokens: 1500 });
|
|
409
|
+
assert.ok(est >= real);
|
|
410
|
+
});
|
package/tests/module_loader.mjs
CHANGED
|
@@ -278,7 +278,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
|
|
|
278
278
|
// That is precisely how the agents regression reached an open PR (task 1002485).
|
|
279
279
|
loader._reset();
|
|
280
280
|
const snap = loader.loaderSnapshot();
|
|
281
|
-
assert.deepEqual(snap.loaded, ['agents', 'ai-studio', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'market-watch', 'memory', 'npm-release', 'onboarding', 'page-notes', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'raise', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
|
|
281
|
+
assert.deepEqual(snap.loaded, ['agents', 'ai-studio', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'front-door-guide', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'market-watch', 'memory', 'npm-release', 'onboarding', 'page-notes', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'raise', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
|
|
282
282
|
assert.deepEqual(snap.errors, []);
|
|
283
283
|
assert.deepEqual(snap.skipped, []);
|
|
284
284
|
loader._reset();
|