@bongos/core 1.25.12 → 1.25.14

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 (55) hide show
  1. package/.bongos-core.json +106 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +13 -4
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md +1 -1
  8. package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +124 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +198 -11
  11. package/docs/api-reference.md +7 -4
  12. package/docs/copy-inventory.md +28 -34
  13. package/docs/copy-registry.json +41 -125
  14. package/docs/file-map.md +1 -1
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +1 -0
  17. package/docs/page-readings.json +3 -3
  18. package/modules/front-door-guide/claude.js +11 -7
  19. package/modules/front-door-guide/context.js +54 -0
  20. package/modules/front-door-guide/db.js +79 -6
  21. package/modules/front-door-guide/guide.js +18 -3
  22. package/modules/front-door-guide/migrations/front_door_guide_002_plan_and_account.sql +37 -0
  23. package/modules/front-door-guide/migrations/front_door_guide_003_starting_point.sql +12 -0
  24. package/modules/front-door-guide/module.json +7 -3
  25. package/modules/front-door-guide/plan.js +102 -0
  26. package/modules/front-door-guide/pollers/retention-sweep.js +67 -0
  27. package/modules/front-door-guide/routes/front-door-guide-plan.js +145 -0
  28. package/modules/front-door-guide/routes/front-door-guide.js +36 -33
  29. package/modules/front-door-guide/settings.js +4 -0
  30. package/modules/front-door-guide/starting-point.js +55 -0
  31. package/modules/hall-ui/public/genesis-home.js +29 -2
  32. package/modules/hall-ui/public/goals-page.js +3 -29
  33. package/modules/hall-ui/public/index.html +2 -1
  34. package/modules/hall-ui/public/task-lib.js +45 -0
  35. package/modules/hall-ui/public/work.js +3 -47
  36. package/modules/lifecycle/kickoff-seed.js +9 -5
  37. package/modules/onboarding/genesis-home.js +1 -1
  38. package/modules/provisioning/module.json +2 -1
  39. package/modules/provisioning/seams.js +10 -0
  40. package/package-lock.json +306 -3
  41. package/package.json +4 -2
  42. package/release-notes.json +24 -0
  43. package/scripts/bongos/package-core.js +4 -2
  44. package/scripts/bongos/run-unit-tests.js +4 -0
  45. package/src/bongos/module-scope-map.js +2 -0
  46. package/src/module-api.js +1 -1
  47. package/tests/cutover_codemod.mjs +1 -1
  48. package/tests/front_door_guide_db.mjs +115 -0
  49. package/tests/front_door_guide_existing.mjs +238 -0
  50. package/tests/front_door_guide_plan.mjs +344 -0
  51. package/tests/genesis_home.mjs +80 -2
  52. package/tests/package_core.mjs +11 -7
  53. package/tests/pruned_lockfile_integrity.mjs +4 -2
  54. package/tests/status_page_no_dead_uptime.mjs +1 -1
  55. package/tests/task_lib.mjs +46 -0
@@ -0,0 +1,238 @@
1
+ // tests/front_door_guide_existing.mjs — the guide's branch for a visitor who already has
2
+ // something (task 1005279): the guide asks early whether they are starting fresh, asks an
3
+ // existing-project visitor what they have and what they want next, and plans from their
4
+ // DESCRIPTION. The owner's hard rule: nothing they have made is read on the platform's AI
5
+ // bill — no upload, no fetch, no link reading — so the tests pin the absence too: the
6
+ // requests carry text only and no tools, no outbound request is made besides the (mocked)
7
+ // model call, and the guide's routes have no upload path.
8
+ //
9
+ // The real routers over an in-memory db and a mocked Claude client: no key, no network,
10
+ // no spend.
11
+
12
+ import { test } from 'node:test';
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import http from 'node:http';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const db = require('../modules/front-door-guide/db.js');
19
+ const claude = require('../modules/front-door-guide/claude.js');
20
+ const SP = require('../modules/front-door-guide/starting-point.js');
21
+ require('../modules/provisioning/seams.js').registerProvisioningSeams();
22
+
23
+ const ENV = 'BONGOS_FRONT_DOOR_GUIDE_';
24
+
25
+ // ---- in-memory db --------------------------------------------------------------------
26
+
27
+ const convs = new Map();
28
+ let calls = [];
29
+ let seq = 0;
30
+ const copy = (c) => c && JSON.parse(JSON.stringify(c));
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(), plan: null, builder_id: null, starting_point: null };
34
+ convs.set(c.id, c);
35
+ return copy(c);
36
+ },
37
+ setStartingPoint: async (id, sp) => { const c = convs.get(String(id)); c.starting_point = sp; return copy(c); },
38
+ getConversation: async (id, tokenHash) => { const c = convs.get(String(id)); return c && c.token_hash === tokenHash ? copy(c) : null; },
39
+ siteSpendMicro: async () => calls.reduce((s, r) => s + r.cost_micro, 0),
40
+ visitorUsage: async (v) => {
41
+ const mine = calls.filter((r) => r.visitor_hash === v);
42
+ return { messages: mine.filter((r) => r.kind === 'reply').length, spendMicro: mine.reduce((s, r) => s + r.cost_micro, 0) };
43
+ },
44
+ recordTurn: async (id, { messages, status, endReason }, call) => {
45
+ calls.push({ visitor_hash: call.visitorHash, cost_micro: call.costMicro, kind: 'reply' });
46
+ const c = convs.get(String(id));
47
+ Object.assign(c, { messages, status, end_reason: endReason, visitor_messages: c.visitor_messages + 1 });
48
+ return copy(c);
49
+ },
50
+ endConversation: async (id, reason) => Object.assign(convs.get(String(id)), { status: 'ended', end_reason: reason }),
51
+ savePlan: async (id, plan, call) => {
52
+ calls.push({ visitor_hash: call.visitorHash, cost_micro: call.costMicro, kind: 'plan' });
53
+ const c = convs.get(String(id));
54
+ Object.assign(c, { plan: c.plan || plan, status: 'ended', end_reason: 'planned' });
55
+ return copy(c);
56
+ },
57
+ recordPlanSpend: async () => {},
58
+ });
59
+
60
+ // ---- the mocked model, and a trap for any other outbound request ----------------------------
61
+
62
+ let requests = [];
63
+ let planText = null;
64
+ claude.createClient = () => ({ messages: { create: async (req) => {
65
+ requests.push(req);
66
+ if (req.output_config && req.output_config.format) {
67
+ return { model: 'claude-opus-5-5', stop_reason: 'end_turn', usage: { input_tokens: 1500, output_tokens: 500 }, content: [{ type: 'text', text: planText }] };
68
+ }
69
+ return { model: 'claude-opus-5-5', stop_reason: 'end_turn', usage: { input_tokens: 700, output_tokens: 150 }, content: [{ type: 'text', text: 'What would you like to build next?' }] };
70
+ } } });
71
+
72
+ let outbound = [];
73
+ const realFetch = globalThis.fetch;
74
+ globalThis.fetch = async (url) => { outbound.push(String(url)); throw new Error('no outbound requests in this test'); };
75
+
76
+ // ---- the real routers over http -----------------------------------------------------------
77
+
78
+ const express = require('express');
79
+ const app = express();
80
+ app.set('trust proxy', true);
81
+ app.use(express.json({ limit: '64kb' }));
82
+ const chatRouter = require('../modules/front-door-guide/routes/front-door-guide.js')();
83
+ const planRouter = require('../modules/front-door-guide/routes/front-door-guide-plan.js')();
84
+ app.use('/api/bongos', chatRouter);
85
+ app.use('/api/bongos', planRouter);
86
+ const server = app.listen(0);
87
+ await new Promise((r) => server.once('listening', r));
88
+ const PORT = server.address().port;
89
+ test.after(() => { server.close(); globalThis.fetch = realFetch; });
90
+
91
+ let ipSeq = 0;
92
+ function call(method, path, body) {
93
+ return new Promise((resolve, reject) => {
94
+ const data = body === undefined ? null : JSON.stringify(body);
95
+ const req = http.request({ host: '127.0.0.1', port: PORT, method, path: `/api/bongos${path}`,
96
+ headers: { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (test browser)', 'x-forwarded-for': `10.7.0.${++ipSeq}` } }, (res) => {
97
+ let buf = '';
98
+ res.on('data', (c) => { buf += c; });
99
+ res.on('end', () => resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }));
100
+ });
101
+ req.on('error', reject);
102
+ if (data) req.write(data);
103
+ req.end();
104
+ });
105
+ }
106
+
107
+ function reset() {
108
+ for (const k of Object.keys(process.env)) if (k.startsWith(ENV)) delete process.env[k];
109
+ Object.assign(process.env, { [`${ENV}ANTHROPIC_API_KEY`]: 'sk-ant-TEST-ONLY-0123456789abcdef', [`${ENV}MIN_FIRST_MESSAGE_MS`]: '1', [`${ENV}STARTS_PER_HOUR`]: '1000', [`${ENV}MESSAGES_PER_MINUTE`]: '1000' });
110
+ convs.clear(); calls = []; requests = []; outbound = []; planText = null;
111
+ }
112
+
113
+ // Every request the platform sent its AI: text only, no tools, nothing attached.
114
+ function assertTextOnly(reqs) {
115
+ for (const r of reqs) {
116
+ assert.equal(r.tools, undefined, 'no tools: the model cannot fetch or read anything');
117
+ assert.equal(r.mcp_servers, undefined);
118
+ assert.equal(r.container, undefined);
119
+ assert.equal(typeof r.system, 'string');
120
+ for (const m of r.messages) assert.equal(typeof m.content, 'string', 'no file, image or document blocks');
121
+ }
122
+ assert.deepEqual(outbound, [], 'no other outbound request was made');
123
+ }
124
+
125
+ // ---- asking early -------------------------------------------------------------------------
126
+
127
+ test('without a starting point, the greeting asks it, with two answers to offer', async () => {
128
+ reset();
129
+ const st = await call('POST', '/front-door-guide/conversations', {});
130
+ assert.equal(st.status, 201);
131
+ assert.equal(st.body.greeting, SP.GREETINGS.unknown);
132
+ assert.match(st.body.greeting, /already have something/);
133
+ assert.deepEqual(st.body.starting_point_options, [{ value: 'fresh', label: 'Starting fresh' }, { value: 'existing', label: 'I already have something' }]);
134
+ assert.equal(st.body.conversation.starting_point, null);
135
+
136
+ const conv = st.body.conversation;
137
+ await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { token: conv.token, text: 'I want to make a recipe app.' });
138
+ assert.match(requests[0].system, /do not yet know whether this visitor is starting fresh/);
139
+ assert.match(requests[0].system, /cannot see, open or read any file, repository/);
140
+ });
141
+
142
+ test('a starting point can be given at the start, or with a message; anything else is refused', async () => {
143
+ reset();
144
+ const fresh = await call('POST', '/front-door-guide/conversations', { starting_point: 'fresh' });
145
+ assert.equal(fresh.body.greeting, SP.GREETINGS.fresh);
146
+ assert.equal(fresh.body.conversation.starting_point, 'fresh');
147
+
148
+ const bad = await call('POST', '/front-door-guide/conversations', { starting_point: 'github' });
149
+ assert.equal(bad.status, 400);
150
+
151
+ const st = await call('POST', '/front-door-guide/conversations', {});
152
+ const conv = st.body.conversation;
153
+ const r = await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { token: conv.token, text: 'I already have a half-built shop.', starting_point: 'existing' });
154
+ assert.equal(r.body.mode, 'chat');
155
+ assert.equal(r.body.conversation.starting_point, 'existing');
156
+ assert.match(requests[0].system, /This visitor already has something/, 'the reply already follows the answer');
157
+ assert.equal((await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { token: conv.token, text: 'x', starting_point: 'both' })).status, 400);
158
+ });
159
+
160
+ // ---- the existing-project branch -----------------------------------------------------------
161
+
162
+ test('an existing project: the guide asks what they have and what is next, and plans from their words alone', async () => {
163
+ reset();
164
+ const st = await call('POST', '/front-door-guide/conversations', { starting_point: 'existing' });
165
+ assert.equal(st.body.greeting, SP.GREETINGS.existing);
166
+ assert.match(st.body.greeting, /cannot open files or links/);
167
+ const conv = st.body.conversation;
168
+ const say = (text) => call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { token: conv.token, text });
169
+
170
+ await say('I have a Node app for booking yoga classes, about half done, on github.com/someone/yoga-app.');
171
+ await say('Next I want online payments and a teacher dashboard.');
172
+ const system = requests[0].system;
173
+ assert.match(system, /ask what they have \(code, documents, artwork or something else, and how far along it is\) and what they want to build next/);
174
+ assert.match(system, /Never ask them to paste code, upload files or share links/);
175
+ assert.match(system, /If they paste a link, say you cannot open links/);
176
+
177
+ // The plan: the visitor's answer decides the starting point, even if the model said otherwise.
178
+ planText = JSON.stringify({
179
+ summary: 'Finish a yoga booking app and add payments and a teacher dashboard.',
180
+ starting_point: 'fresh',
181
+ what_you_have: ['A Node booking app, about half done', 'Code on GitHub'],
182
+ project_type: 'business', how_it_works: ['You decide; AI builders do small tasks.'],
183
+ modules: [], steps: [{ title: 'Sign up', detail: '' }, { title: 'Bring in your existing work', detail: 'Your own AI looks through it.' }],
184
+ needs: [{ item: 'A payments account', detail: '' }], open_questions: [],
185
+ });
186
+ const plan = await call('POST', `/front-door-guide/conversations/${conv.id}/plan`, { token: conv.token });
187
+ assert.equal(plan.body.mode, 'plan');
188
+ assert.equal(plan.body.plan.starting_point, 'existing');
189
+ assert.deepEqual(plan.body.plan.what_you_have, ['A Node booking app, about half done', 'Code on GitHub']);
190
+
191
+ const planReq = requests.find((r) => r.output_config.format);
192
+ const props = planReq.output_config.format.schema.properties;
193
+ assert.deepEqual(props.starting_point.enum, ['fresh', 'existing']);
194
+ assert.equal(props.what_you_have.type, 'array');
195
+ assert.match(planReq.system, /"existing" \(the visitor said so\)/);
196
+ assert.match(planReq.system, /Only what they described: you have seen none of it/);
197
+
198
+ // Nothing they have made reached the platform's AI: text only, no tools, no fetch of the link.
199
+ assertTextOnly(requests);
200
+ const sent = requests.flatMap((r) => r.messages.map((m) => m.content)).join('\n');
201
+ assert.ok(sent.includes('github.com/someone/yoga-app'), 'the link is only their words');
202
+ assert.equal(outbound.length, 0, 'and it was never opened');
203
+ });
204
+
205
+ test('a fresh project plans with nothing listed as already made', async () => {
206
+ reset();
207
+ const st = await call('POST', '/front-door-guide/conversations', { starting_point: 'fresh' });
208
+ const conv = st.body.conversation;
209
+ await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { token: conv.token, text: 'A garden planner.' });
210
+ planText = JSON.stringify({ summary: 'A garden planner.', starting_point: 'existing', what_you_have: [], project_type: 'not-sure', how_it_works: [], modules: [], steps: [], needs: [], open_questions: [] });
211
+ const plan = await call('POST', `/front-door-guide/conversations/${conv.id}/plan`, { token: conv.token });
212
+ assert.equal(plan.body.plan.starting_point, 'fresh');
213
+ assert.deepEqual(plan.body.plan.what_you_have, []);
214
+ assert.match(requests[0].system, /starting fresh, from an idea/);
215
+ assertTextOnly(requests);
216
+ });
217
+
218
+ // ---- no path for reading their work ------------------------------------------------------
219
+
220
+ test('the guide has no upload, fetch or link-reading path: these are all its routes', () => {
221
+ const routes = (r) => r.stack.filter((l) => l.route).map((l) => `${Object.keys(l.route.methods)[0].toUpperCase()} ${l.route.path}`);
222
+ assert.deepEqual([...routes(chatRouter), ...routes(planRouter)].sort(), [
223
+ 'GET /front-door-guide/conversations/:id',
224
+ 'GET /front-door-guide/mine',
225
+ 'GET /front-door-guide/status',
226
+ 'POST /front-door-guide/conversations',
227
+ 'POST /front-door-guide/conversations/:id/attach',
228
+ 'POST /front-door-guide/conversations/:id/messages',
229
+ 'POST /front-door-guide/conversations/:id/plan',
230
+ ]);
231
+ });
232
+
233
+ test('every instruction version carries the no-reading rule', () => {
234
+ for (const sp of [null, 'fresh', 'existing']) {
235
+ assert.match(SP.instructionsFor(sp), /You cannot see, open or read any file, repository, code, document, image or link/);
236
+ }
237
+ assert.equal(SP.greetingFor('nonsense'), SP.GREETINGS.unknown);
238
+ });
@@ -0,0 +1,344 @@
1
+ // tests/front_door_guide_plan.mjs — where the start-a-project chat ends (task 1005273): a
2
+ // plan from structured output that names only modules in the live catalog, saved and
3
+ // readable again after a browser restart, attached to the account at sign-up, and swept
4
+ // away when nobody signs up.
5
+ //
6
+ // The REAL routers over an in-memory db and a mocked Claude client (no key, no network, no
7
+ // spend), with the catalog served by the REAL provisioning port, so "only modules in the
8
+ // catalog" is checked against the list the project-creation picker actually offers.
9
+ // The SQL itself (the migration, the sweep, the attach rule) is in
10
+ // tests/front_door_guide_db.mjs, against a real Postgres.
11
+
12
+ import { test } from 'node:test';
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import http from 'node:http';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const api = require('../src/module-api.js');
19
+ const db = require('../modules/front-door-guide/db.js');
20
+ const claude = require('../modules/front-door-guide/claude.js');
21
+ const G = require('../modules/front-door-guide/guide.js');
22
+ const P = require('../modules/front-door-guide/plan.js');
23
+ const sweep = require('../modules/front-door-guide/pollers/retention-sweep.js');
24
+ const { optionalModules } = require('../modules/provisioning/starter-bundles.js');
25
+
26
+ const ENV = 'BONGOS_FRONT_DOOR_GUIDE_';
27
+
28
+ // ---- the live catalog: the provisioning module's own port --------------------------------
29
+
30
+ require('../modules/provisioning/seams.js').registerProvisioningSeams();
31
+ const CATALOG_KEYS = optionalModules().map((m) => m.key);
32
+
33
+ // ---- an in-memory db with the SQL's rules ------------------------------------------------
34
+
35
+ const convs = new Map();
36
+ let calls = [];
37
+ let seq = 0;
38
+ const copy = (c) => c && JSON.parse(JSON.stringify(c));
39
+ const nowIso = () => new Date().toISOString();
40
+ Object.assign(db, {
41
+ createConversation: async (tokenHash, visitorHash) => {
42
+ 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(), updated_at: nowIso(), plan: null, planned_at: null, builder_id: null, attached_at: null };
43
+ convs.set(c.id, c);
44
+ return copy(c);
45
+ },
46
+ getConversation: async (id, tokenHash) => { const c = convs.get(String(id)); return c && c.token_hash === tokenHash ? copy(c) : null; },
47
+ siteSpendMicro: async () => calls.reduce((s, r) => s + r.cost_micro, 0),
48
+ visitorUsage: async (v) => {
49
+ const mine = calls.filter((r) => r.visitor_hash === v);
50
+ return { messages: mine.filter((r) => r.kind === 'reply').length, spendMicro: mine.reduce((s, r) => s + r.cost_micro, 0) };
51
+ },
52
+ recordTurn: async (id, { messages, status, endReason }, call) => {
53
+ calls.push({ conversation_id: String(id), visitor_hash: call.visitorHash, cost_micro: call.costMicro, kind: 'reply' });
54
+ const c = convs.get(String(id));
55
+ Object.assign(c, { messages, status, end_reason: endReason, visitor_messages: c.visitor_messages + 1, updated_at: nowIso() });
56
+ return copy(c);
57
+ },
58
+ endConversation: async (id, reason) => Object.assign(convs.get(String(id)), { status: 'ended', end_reason: reason }),
59
+ savePlan: async (id, plan, call) => {
60
+ calls.push({ conversation_id: String(id), visitor_hash: call.visitorHash, cost_micro: call.costMicro, kind: 'plan' });
61
+ const c = convs.get(String(id));
62
+ Object.assign(c, { plan: c.plan || plan, planned_at: c.planned_at || nowIso(), status: 'ended', end_reason: 'planned', updated_at: nowIso() });
63
+ return copy(c);
64
+ },
65
+ recordPlanSpend: async (id, call) => { calls.push({ conversation_id: String(id), visitor_hash: call.visitorHash, cost_micro: call.costMicro, kind: 'plan' }); },
66
+ attachToBuilder: async (id, tokenHash, builderId) => {
67
+ const c = convs.get(String(id));
68
+ if (!c || c.token_hash !== tokenHash || (c.builder_id != null && c.builder_id !== builderId)) return null;
69
+ Object.assign(c, { builder_id: builderId, attached_at: c.attached_at || nowIso() });
70
+ return copy(c);
71
+ },
72
+ latestForBuilder: async (builderId) => copy([...convs.values()].filter((c) => c.builder_id === builderId).pop() || null),
73
+ });
74
+
75
+ // ---- the mocked Claude client --------------------------------------------------------
76
+
77
+ let requests = [];
78
+ let planReply = null;
79
+ const chatReply = () => ({ model: 'claude-opus-5-5', stop_reason: 'end_turn', content: [{ type: 'text', text: 'Who is it for?' }], usage: { input_tokens: 700, output_tokens: 150 } });
80
+ const goodPlan = (modules) => JSON.stringify({
81
+ summary: 'A booking site for a small yoga studio.',
82
+ project_type: 'business',
83
+ how_it_works: ['You describe what you want.', 'AI builders do small tasks.'],
84
+ modules,
85
+ steps: [{ title: 'Sign up', detail: 'Create your account.' }, { title: 'Create the project', detail: 'Name it.' }],
86
+ needs: [{ item: 'A payments account', detail: 'To take bookings.' }],
87
+ open_questions: ['Do classes repeat weekly?'],
88
+ });
89
+ claude.createClient = () => ({ messages: { create: async (req) => {
90
+ requests.push(req);
91
+ if (req.output_config && req.output_config.format) {
92
+ return planReply ? planReply(req) : { model: 'claude-opus-5-5', stop_reason: 'end_turn', content: [{ type: 'text', text: goodPlan([{ key: CATALOG_KEYS[0], why: 'It fits.' }]) }], usage: { input_tokens: 2000, output_tokens: 600 } };
93
+ }
94
+ return chatReply();
95
+ } } });
96
+
97
+ // ---- a stand-in sign-in: x-test-builder is the signed-in account --------------------------
98
+
99
+ api.requireBuilder = (req, res, next) => {
100
+ const id = req.get('x-test-builder');
101
+ if (!id) return res.status(401).json({ error: 'unauthenticated' });
102
+ req.builder = { id: Number(id) };
103
+ return next();
104
+ };
105
+
106
+ const express = require('express');
107
+ const app = express();
108
+ app.set('trust proxy', true);
109
+ app.use(express.json({ limit: '64kb' }));
110
+ app.use('/api/bongos', require('../modules/front-door-guide/routes/front-door-guide.js')());
111
+ app.use('/api/bongos', require('../modules/front-door-guide/routes/front-door-guide-plan.js')());
112
+ const server = app.listen(0);
113
+ await new Promise((r) => server.once('listening', r));
114
+ const PORT = server.address().port;
115
+ test.after(() => server.close());
116
+
117
+ let ipSeq = 0;
118
+ function call(method, path, { body, ip, headers = {} } = {}) {
119
+ return new Promise((resolve, reject) => {
120
+ const data = body === undefined ? null : JSON.stringify(body);
121
+ const h = { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (test browser)', 'x-forwarded-for': ip || `10.9.0.${++ipSeq}`, ...headers };
122
+ const req = http.request({ host: '127.0.0.1', port: PORT, method, path: `/api/bongos${path}`, headers: h }, (res) => {
123
+ let buf = '';
124
+ res.on('data', (c) => { buf += c; });
125
+ res.on('end', () => resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }));
126
+ });
127
+ req.on('error', reject);
128
+ if (data) req.write(data);
129
+ req.end();
130
+ });
131
+ }
132
+
133
+ function reset(over = {}) {
134
+ for (const k of Object.keys(process.env)) if (k.startsWith(ENV)) delete process.env[k];
135
+ const all = { ANTHROPIC_API_KEY: 'sk-ant-TEST-ONLY-0123456789abcdef', MIN_FIRST_MESSAGE_MS: '1', STARTS_PER_HOUR: '1000', MESSAGES_PER_MINUTE: '1000', ...over };
136
+ for (const [k, v] of Object.entries(all)) if (v !== null) process.env[ENV + k] = String(v);
137
+ convs.clear(); calls = []; requests = []; planReply = null;
138
+ }
139
+
140
+ async function chatWithOneMessage(ip) {
141
+ const st = await call('POST', '/front-door-guide/conversations', { body: {}, ip });
142
+ const conv = st.body.conversation;
143
+ await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { body: { token: conv.token, text: 'A booking site for my yoga studio.' }, ip });
144
+ return { conv, start: st.body };
145
+ }
146
+ const askPlan = (conv, ip) => call('POST', `/front-door-guide/conversations/${conv.id}/plan`, { body: { token: conv.token }, ip });
147
+
148
+ // ---- the plan -----------------------------------------------------------------------------
149
+
150
+ test('the catalog the plan reads is the provisioning picker\'s own list', async () => {
151
+ const port = api.resolveOptional('provisioning.moduleCatalog');
152
+ assert.equal(typeof port, 'function');
153
+ const c = await port();
154
+ assert.deepEqual(c.optional.map((m) => m.key), CATALOG_KEYS);
155
+ assert.ok(CATALOG_KEYS.length > 0);
156
+ for (const m of c.optional) assert.ok(m.label && m.summary, `${m.key} carries owner-facing words`);
157
+ });
158
+
159
+ test('the plan is structured output and names only catalog modules, in the catalog\'s words', async () => {
160
+ reset();
161
+ const ip = '10.8.0.1';
162
+ const { conv } = await chatWithOneMessage(ip);
163
+ planReply = () => ({
164
+ model: 'claude-opus-5-5', stop_reason: 'end_turn', usage: { input_tokens: 2000, output_tokens: 600 },
165
+ content: [{ type: 'text', text: goodPlan([
166
+ { key: CATALOG_KEYS[0], why: 'Fits.' },
167
+ { key: 'made-up-module', why: 'Invented.' },
168
+ { key: 'lifecycle', why: 'Core, not optional.' },
169
+ { key: CATALOG_KEYS[0], why: 'Twice.' },
170
+ ]) }],
171
+ });
172
+ const r = await askPlan(conv, ip);
173
+ assert.equal(r.status, 200);
174
+ assert.equal(r.body.mode, 'plan');
175
+ assert.deepEqual(r.body.next, { action: 'sign_up', label: 'Sign up and get started' });
176
+ const real = optionalModules().find((m) => m.key === CATALOG_KEYS[0]);
177
+ assert.deepEqual(r.body.plan.modules, [{ key: real.key, label: real.label, summary: real.summary, why: 'Fits.' }]);
178
+ assert.equal(r.body.plan.project_type, 'business');
179
+ assert.equal(r.body.plan.cost_and_time, P.COST_AND_TIME, 'cost and time are named, never estimated');
180
+ assert.ok(r.body.plan.steps.length && r.body.plan.needs.length);
181
+
182
+ // The request asked for JSON against a schema whose module keys ARE the catalog.
183
+ const req = requests.find((q) => q.output_config && q.output_config.format);
184
+ assert.equal(req.output_config.format.type, 'json_schema');
185
+ assert.deepEqual(req.output_config.format.schema.properties.modules.items.properties.key.enum, CATALOG_KEYS);
186
+ assert.equal(req.max_tokens, 4000);
187
+ for (const k of CATALOG_KEYS) assert.ok(req.system.includes(`- ${k}:`), `the instructions list ${k}`);
188
+ assert.equal(calls.filter((c) => c.kind === 'plan').length, 1, 'the plan call is recorded as spend');
189
+ });
190
+
191
+ test('a plan is written once: asking again re-reads it, and the chat now ends with sign-up', async () => {
192
+ reset();
193
+ const ip = '10.8.0.2';
194
+ const { conv } = await chatWithOneMessage(ip);
195
+ const first = await askPlan(conv, ip);
196
+ const again = await askPlan(conv, ip);
197
+ assert.deepEqual(again.body.plan, first.body.plan);
198
+ assert.equal(requests.filter((q) => q.output_config.format).length, 1);
199
+ const more = await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { body: { token: conv.token, text: 'one more thing' }, ip });
200
+ assert.equal(more.body.mode, 'ended');
201
+ assert.equal(more.body.reason, 'planned');
202
+ assert.equal(more.body.next_step, 'sign_up');
203
+ });
204
+
205
+ test('no plan before the visitor has said anything; a plan does not use up a message', async () => {
206
+ reset({ MAX_MESSAGES: 2 });
207
+ const ip = '10.8.0.3';
208
+ const st = await call('POST', '/front-door-guide/conversations', { body: {}, ip });
209
+ assert.equal((await askPlan(st.body.conversation, ip)).status, 409);
210
+ const { conv } = await chatWithOneMessage(ip);
211
+ await askPlan(conv, ip);
212
+ const used = await db.visitorUsage(convs.get(conv.id).visitor_hash);
213
+ assert.equal(used.messages, 1, 'only the reply counts as a message');
214
+ });
215
+
216
+ test('a plan that cannot be used (refused, cut off, not JSON) costs what it cost and offers the form', async () => {
217
+ for (const reply of [
218
+ { stop_reason: 'refusal', content: [] },
219
+ { stop_reason: 'max_tokens', content: [{ type: 'text', text: '{"summary": "half' }] },
220
+ { stop_reason: 'end_turn', content: [{ type: 'text', text: 'not json' }] },
221
+ ]) {
222
+ reset();
223
+ const ip = `10.8.1.${calls.length + Math.floor(Math.random() * 200)}`;
224
+ const { conv } = await chatWithOneMessage(ip);
225
+ planReply = () => ({ model: 'claude-opus-5-5', usage: { input_tokens: 2000, output_tokens: 50 }, ...reply });
226
+ const r = await askPlan(conv, ip);
227
+ assert.deepEqual(r.body, G.formAnswer('unavailable'));
228
+ assert.equal(convs.get(conv.id).plan, null);
229
+ assert.equal(calls.filter((c) => c.kind === 'plan').length, 1, `${reply.stop_reason}: the spend is recorded`);
230
+ }
231
+ });
232
+
233
+ test('the plan is held to the site budget like a reply', async () => {
234
+ reset({ DAILY_BUDGET_CENTS: 1 });
235
+ const ip = '10.8.0.4';
236
+ const st = await call('POST', '/front-door-guide/conversations', { body: {}, ip });
237
+ convs.get(st.body.conversation.id).visitor_messages = 1; // as if they had talked
238
+ const r = await askPlan(st.body.conversation, ip);
239
+ assert.deepEqual(r.body, G.formAnswer('daily_budget'));
240
+ assert.equal(requests.length, 0);
241
+ });
242
+
243
+ // ---- saved, resumed, attached --------------------------------------------------------------
244
+
245
+ test('the visitor is told the chat is saved, and after a browser restart it all comes back', async () => {
246
+ reset({ RETENTION_DAYS: 21 });
247
+ const ip = '10.8.0.5';
248
+ const { conv, start } = await chatWithOneMessage(ip);
249
+ assert.deepEqual(start.saved, G.savedNotice(21));
250
+ assert.match(start.saved.message, /saved/);
251
+ assert.match(start.saved.message, /21 days/);
252
+ await askPlan(conv, ip);
253
+
254
+ // A new browser session: nothing but the id and token the page kept, from a new address.
255
+ const back = await call('GET', `/front-door-guide/conversations/${conv.id}`, { ip: '10.8.200.1', headers: { 'x-guide-token': conv.token } });
256
+ assert.equal(back.status, 200);
257
+ assert.equal(back.body.transcript.length, 2);
258
+ assert.equal(back.body.plan.summary, 'A booking site for a small yoga studio.');
259
+ assert.deepEqual(back.body.next, P.NEXT_STEP);
260
+ assert.equal(back.body.attached, false);
261
+ assert.equal(back.body.saved.keep_days, 21);
262
+ });
263
+
264
+ test('at sign-up the chat and plan move onto the new account, and only that account', async () => {
265
+ reset();
266
+ const ip = '10.8.0.6';
267
+ const { conv } = await chatWithOneMessage(ip);
268
+ await askPlan(conv, ip);
269
+ const attach = (builder, token = conv.token) => call('POST', `/front-door-guide/conversations/${conv.id}/attach`,
270
+ { body: { token }, headers: builder ? { 'x-test-builder': String(builder) } : {} });
271
+
272
+ assert.equal((await attach(null)).status, 401, 'attaching needs a signed-in account');
273
+ assert.equal((await attach(77, 'Z'.repeat(32))).status, 404, 'and the chat\'s token');
274
+ const ok = await attach(77);
275
+ assert.equal(ok.status, 200);
276
+ assert.equal(ok.body.conversation.plan.summary, 'A booking site for a small yoga studio.');
277
+ assert.ok(!('visitor_hash' in ok.body.conversation));
278
+ assert.equal((await attach(77)).status, 200, 'attaching again is harmless');
279
+ assert.equal((await attach(88)).status, 404, 'another account cannot take it');
280
+
281
+ const mine = await call('GET', '/front-door-guide/mine', { headers: { 'x-test-builder': '77' } });
282
+ assert.equal(mine.body.conversation.id, conv.id);
283
+ assert.deepEqual(mine.body.conversation.next, P.NEXT_STEP);
284
+ assert.equal(mine.body.conversation.transcript[0].role, 'visitor');
285
+ const theirs = await call('GET', '/front-door-guide/mine', { headers: { 'x-test-builder': '88' } });
286
+ assert.equal(theirs.body.conversation, null);
287
+ assert.equal((await call('GET', '/front-door-guide/mine')).status, 401);
288
+ });
289
+
290
+ // ---- the retention sweep ---------------------------------------------------------------
291
+
292
+ test('the retention sweep deletes after the owner\'s number of days, and only unclaimed chats', async () => {
293
+ reset({ RETENTION_DAYS: 14 });
294
+ const DAY = 86_400_000;
295
+ const rows = [
296
+ { id: 'old', builder_id: null, updated_at: Date.now() - 15 * DAY },
297
+ { id: 'recent', builder_id: null, updated_at: Date.now() - 13 * DAY },
298
+ { id: 'attached', builder_id: 5, updated_at: Date.now() - 400 * DAY },
299
+ ];
300
+ const spend = [{ conversation_id: 'old', cost_micro: 9000 }];
301
+ let askedDays = null;
302
+ // The same rule as db.deleteAbandoned's SQL, over the rows above.
303
+ const deleteAbandoned = async (days) => {
304
+ askedDays = days;
305
+ const gone = rows.filter((r) => r.builder_id == null && r.updated_at < Date.now() - days * DAY);
306
+ for (const g of gone) {
307
+ rows.splice(rows.indexOf(g), 1);
308
+ for (const s of spend) if (s.conversation_id === g.id) s.conversation_id = null;
309
+ }
310
+ return gone.length;
311
+ };
312
+ const r = await sweep.tick({ deleteAbandoned });
313
+ assert.deepEqual(r, { deleted: 1 });
314
+ assert.equal(askedDays, 14, 'the sweep reads the retention setting');
315
+ assert.deepEqual(rows.map((x) => x.id), ['recent', 'attached']);
316
+ assert.deepEqual(spend, [{ conversation_id: null, cost_micro: 9000 }], 'the money stays on the books');
317
+ assert.equal(sweep.TICK_MS, 3_600_000);
318
+
319
+ const failed = await sweep.tick({ deleteAbandoned: async () => { throw new Error('connection lost'); } });
320
+ assert.equal(failed.skipped, 'error');
321
+
322
+ // db.deleteAbandoned sends the setting as the day count and spares claimed chats.
323
+ let sent = null;
324
+ await db.deleteAbandoned.call(null, 14, async (sql, params) => { sent = { sql, params }; return { rowCount: 0 }; });
325
+ assert.deepEqual(sent.params, [14]);
326
+ assert.match(sent.sql, /builder_id IS NULL/);
327
+ });
328
+
329
+ test('plan sanitising: strings trimmed and capped, lists capped, an unknown type is not-sure', () => {
330
+ const catalog = { optional: [{ key: 'discord', label: 'Discord', summary: 'Chat.' }] };
331
+ const plan = P.sanitizePlan({
332
+ summary: ` ${'x'.repeat(2000)} `, project_type: 'casino',
333
+ how_it_works: Array(20).fill('point'), modules: [{ key: 'discord', why: 'yes' }],
334
+ steps: Array(30).fill({ title: 't', detail: 'd' }), needs: [{ item: '', detail: 'dropped' }], open_questions: [],
335
+ }, catalog);
336
+ assert.equal(plan.summary.length, 600);
337
+ assert.equal(plan.project_type, 'not-sure');
338
+ assert.equal(plan.how_it_works.length, 6);
339
+ assert.equal(plan.steps.length, 10);
340
+ assert.deepEqual(plan.needs, []);
341
+ assert.equal(P.sanitizePlan({ summary: '' }, catalog), null);
342
+ const noCatalog = P.planSchema([]);
343
+ assert.equal(noCatalog.properties.modules.items.properties.key.enum, undefined);
344
+ });