@bongos/core 1.25.11 → 1.25.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.bongos-core.json +120 -30
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +16 -0
  4. package/clients/bongos-client/index.cjs +16 -0
  5. package/clients/bongos-client/index.d.ts +22 -0
  6. package/clients/bongos-client/index.mjs +16 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +108 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +376 -3
  10. package/docs/api-reference.md +13 -1
  11. package/docs/file-map.md +1 -0
  12. package/docs/module-api-changelog.md +4 -0
  13. package/modules/front-door-guide/CLAUDE.md +15 -0
  14. package/modules/front-door-guide/claude.js +55 -0
  15. package/modules/front-door-guide/context.js +54 -0
  16. package/modules/front-door-guide/db.js +137 -0
  17. package/modules/front-door-guide/guide.js +148 -0
  18. package/modules/front-door-guide/limits.js +87 -0
  19. package/modules/front-door-guide/migrations/front_door_guide_001_conversations.sql +48 -0
  20. package/modules/front-door-guide/migrations/front_door_guide_002_plan_and_account.sql +37 -0
  21. package/modules/front-door-guide/module.json +31 -0
  22. package/modules/front-door-guide/plan.js +93 -0
  23. package/modules/front-door-guide/pollers/retention-sweep.js +67 -0
  24. package/modules/front-door-guide/routes/front-door-guide-plan.js +144 -0
  25. package/modules/front-door-guide/routes/front-door-guide.js +196 -0
  26. package/modules/front-door-guide/settings.js +72 -0
  27. package/modules/provisioning/module.json +2 -1
  28. package/modules/provisioning/seams.js +10 -0
  29. package/modules/provisioning/starter-bundles.js +3 -1
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/release-notes.json +16 -0
  33. package/scripts/bongos/publish-manifest.js +1 -0
  34. package/scripts/bongos/run-unit-tests.js +4 -0
  35. package/src/bongos/module-scope-map.js +6 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/front_door_guide.mjs +410 -0
  38. package/tests/front_door_guide_db.mjs +110 -0
  39. package/tests/front_door_guide_plan.mjs +344 -0
  40. 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
+ });
@@ -0,0 +1,110 @@
1
+ // tests/front_door_guide_db.mjs — the start-a-project guide's SQL against a REAL Postgres
2
+ // (tasks 1005272, 1005273).
3
+ //
4
+ // What only a real engine can say: both migrations apply, and apply again as no-ops; a
5
+ // turn and a plan land in one statement each; only replies count as a visitor's messages;
6
+ // a chat attaches to one account and is never moved to another; and the retention sweep
7
+ // deletes an abandoned chat after the set number of days while its spend stays in the
8
+ // daily and monthly sums.
9
+ //
10
+ // The module is default:false, so CI's migrate.sh does not apply its migrations; this
11
+ // file applies them itself. Rows use a visitor hash and builder ids no real instance has,
12
+ // and are deleted before and after. Self-skips without a reachable Postgres; in the
13
+ // INTEGRATION set (scripts/bongos/run-unit-tests.js) so the DB-free lane never runs it.
14
+ //
15
+ // Run: DATABASE_URL=... node tests/front_door_guide_db.mjs
16
+
17
+ import { createRequire } from 'node:module';
18
+ import { readFileSync } from 'node:fs';
19
+ import { strict as assert } from 'node:assert';
20
+ import { insertTestBuilders } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { Pool } = require('pg');
24
+ const db = require('../modules/front-door-guide/db.js');
25
+ const sql = (n) => readFileSync(new URL(`../modules/front-door-guide/migrations/${n}`, import.meta.url), 'utf8');
26
+ const M1 = sql('front_door_guide_001_conversations.sql');
27
+ const M2 = sql('front_door_guide_002_plan_and_account.sql');
28
+
29
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
30
+ try {
31
+ await pool.query('SELECT 1');
32
+ } catch (e) {
33
+ console.log(`front_door_guide_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
34
+ await pool.end().catch(() => {});
35
+ process.exit(0);
36
+ }
37
+
38
+ const x = (text, params) => pool.query(text, params);
39
+ const V = 't1005273-visitor';
40
+ const ALICE = 91005273, BOB = 91005274;
41
+
42
+ async function cleanup() {
43
+ await x(`DELETE FROM front_door_guide_calls WHERE visitor_hash LIKE $1`, [`${V}%`]).catch(() => {});
44
+ await x(`DELETE FROM front_door_guide_conversations WHERE visitor_hash LIKE $1`, [`${V}%`]).catch(() => {});
45
+ await x('DELETE FROM builders WHERE id = ANY($1)', [[ALICE, BOB]]).catch(() => {});
46
+ }
47
+
48
+ const call = (over = {}) => ({ visitorHash: V, model: 'claude-opus-5-5', inputTokens: 800, outputTokens: 200, cacheReadTokens: 0, cacheWriteTokens: 0, costMicro: 9000, stopReason: 'end_turn', ...over });
49
+
50
+ try {
51
+ for (const m of [M1, M2, M1, M2]) await x(m); // applies, then re-applies as a no-op
52
+ await cleanup();
53
+ await insertTestBuilders(pool, [{ id: ALICE, login: 't1005273-alice' }, { id: BOB, login: 't1005273-bob' }]);
54
+
55
+ const dayBefore = await db.siteSpendMicro('day', x);
56
+
57
+ // A turn and a plan.
58
+ const conv = await db.createConversation('hash-a-1005273', V, x);
59
+ const turned = await db.recordTurn(conv.id, { messages: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }], status: 'open', endReason: null }, call(), x);
60
+ assert.equal(turned.visitor_messages, 1);
61
+ assert.equal(Number(turned.spend_micro), 9000);
62
+ const planned = await db.savePlan(conv.id, { summary: 'A plan' }, call({ costMicro: 20000 }), x);
63
+ assert.equal(planned.plan.summary, 'A plan');
64
+ assert.equal(planned.status, 'ended');
65
+ assert.equal(planned.end_reason, 'planned');
66
+ const again = await db.savePlan(conv.id, { summary: 'Overwrite?' }, call({ costMicro: 1 }), x);
67
+ assert.equal(again.plan.summary, 'A plan', 'a stored plan is never overwritten');
68
+ await db.recordPlanSpend(conv.id, call({ costMicro: 5 }), x);
69
+
70
+ const usage = await db.visitorUsage(V, x);
71
+ assert.equal(usage.messages, 1, 'only replies are messages');
72
+ assert.equal(usage.spendMicro, 9000 + 20000 + 1 + 5);
73
+
74
+ // Attach: the token holder's account, once; never moved to another.
75
+ assert.equal(await db.attachToBuilder(conv.id, 'wrong-hash', ALICE, x), null);
76
+ const mine = await db.attachToBuilder(conv.id, 'hash-a-1005273', ALICE, x);
77
+ assert.equal(Number(mine.builder_id), ALICE);
78
+ assert.ok(mine.attached_at);
79
+ assert.equal(await db.attachToBuilder(conv.id, 'hash-a-1005273', BOB, x), null);
80
+ assert.equal((await db.latestForBuilder(ALICE, x)).plan.summary, 'A plan');
81
+ assert.equal(await db.latestForBuilder(BOB, x), null);
82
+
83
+ // The sweep: an abandoned chat older than the setting goes; a recent one and an
84
+ // attached one stay; the abandoned chat's spend stays on the books.
85
+ const abandoned = await db.createConversation('hash-b-1005273', V, x);
86
+ await db.recordTurn(abandoned.id, { messages: [], status: 'open', endReason: null }, call({ costMicro: 7000 }), x);
87
+ const recent = await db.createConversation('hash-c-1005273', V, x);
88
+ await x(`UPDATE front_door_guide_conversations SET updated_at = now() - interval '15 days' WHERE id = ANY($1)`, [[abandoned.id, conv.id]]);
89
+ await x(`UPDATE front_door_guide_conversations SET updated_at = now() - interval '13 days' WHERE id = $1`, [recent.id]);
90
+ const before = await db.siteSpendMicro('day', x);
91
+
92
+ const deleted = await db.deleteAbandoned(14, x);
93
+ assert.ok(deleted >= 1);
94
+ const left = (await x(`SELECT id FROM front_door_guide_conversations WHERE visitor_hash = $1 ORDER BY id`, [V])).rows.map((r) => String(r.id));
95
+ assert.deepEqual(left, [String(conv.id), String(recent.id)], 'the abandoned chat is gone; the attached and the recent stay');
96
+ const orphan = await x(`SELECT conversation_id, cost_micro FROM front_door_guide_calls WHERE visitor_hash = $1 AND cost_micro = 7000`, [V]);
97
+ assert.equal(orphan.rows.length, 1);
98
+ assert.equal(orphan.rows[0].conversation_id, null);
99
+ assert.equal(await db.siteSpendMicro('day', x), before, 'the budgets still count the deleted chat\'s money');
100
+ assert.ok(before >= dayBefore + 9000 + 20000 + 1 + 5 + 7000);
101
+
102
+ // Refusals the schema owns.
103
+ await assert.rejects(x(`INSERT INTO front_door_guide_calls (visitor_hash, model, cost_micro, kind) VALUES ($1, 'm', 1, 'other')`, [V]));
104
+ await assert.rejects(x(`INSERT INTO front_door_guide_calls (visitor_hash, model, cost_micro) VALUES ($1, 'm', -1)`, [V]));
105
+
106
+ console.log('front_door_guide_db.mjs: all checks passed');
107
+ } finally {
108
+ await cleanup();
109
+ await pool.end();
110
+ }