@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.
@@ -0,0 +1,48 @@
1
+ -- front_door_guide_001_conversations.sql — the start-a-project guide's conversations
2
+ -- and its spend ledger (task 1005272).
3
+ --
4
+ -- front_door_guide_conversations one signed-out visitor's chat: the transcript, how
5
+ -- many messages they sent, what it cost, and whether
6
+ -- it has ended. The visitor proves it is theirs with a
7
+ -- random token; only its SHA-256 is stored.
8
+ -- front_door_guide_calls one row per Claude call, with the API's own usage
9
+ -- numbers and the price in micro-dollars. The daily,
10
+ -- monthly and per-visitor caps are sums over it.
11
+ --
12
+ -- No raw IP address is stored: `visitor_hash` is a salted hash of it.
13
+
14
+ CREATE TABLE IF NOT EXISTS front_door_guide_conversations (
15
+ id BIGSERIAL PRIMARY KEY,
16
+ token_hash TEXT NOT NULL UNIQUE,
17
+ visitor_hash TEXT NOT NULL,
18
+ messages JSONB NOT NULL DEFAULT '[]'::jsonb,
19
+ visitor_messages INTEGER NOT NULL DEFAULT 0,
20
+ spend_micro BIGINT NOT NULL DEFAULT 0,
21
+ status TEXT NOT NULL DEFAULT 'open',
22
+ end_reason TEXT,
23
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
24
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
25
+ CONSTRAINT front_door_guide_conversations_status_chk CHECK (status IN ('open', 'ended')),
26
+ CONSTRAINT front_door_guide_conversations_money_chk CHECK (spend_micro >= 0 AND visitor_messages >= 0)
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS front_door_guide_conversations_visitor_idx
30
+ ON front_door_guide_conversations (visitor_hash, created_at DESC);
31
+
32
+ CREATE TABLE IF NOT EXISTS front_door_guide_calls (
33
+ id BIGSERIAL PRIMARY KEY,
34
+ conversation_id BIGINT NOT NULL REFERENCES front_door_guide_conversations(id) ON DELETE CASCADE,
35
+ visitor_hash TEXT NOT NULL,
36
+ model TEXT NOT NULL,
37
+ input_tokens INTEGER NOT NULL DEFAULT 0,
38
+ output_tokens INTEGER NOT NULL DEFAULT 0,
39
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
40
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
41
+ cost_micro BIGINT NOT NULL,
42
+ stop_reason TEXT,
43
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
44
+ CONSTRAINT front_door_guide_calls_cost_chk CHECK (cost_micro >= 0)
45
+ );
46
+
47
+ CREATE INDEX IF NOT EXISTS front_door_guide_calls_created_idx ON front_door_guide_calls (created_at);
48
+ CREATE INDEX IF NOT EXISTS front_door_guide_calls_visitor_idx ON front_door_guide_calls (visitor_hash, created_at);
@@ -0,0 +1,27 @@
1
+ {
2
+ "key": "front-door-guide",
3
+ "title": "Start-a-project guide",
4
+ "description": "A signed-out visitor who presses start a project talks with an AI guide about what they want to build, for a few capped messages, before signing up (task 1005272, goal 1000106). Runs on the platform's own Anthropic key read from the environment; every cap is enforced on the server (messages and spend per visitor, a daily and a monthly site budget, message length, rate limits, a bot check), and a spent budget or a missing key answers with the plain form instead of an error. default:false: the hub turns it on.",
5
+ "version": "1.0.0",
6
+ "coreVersion": "^1.19.0",
7
+ "category": {
8
+ "parent": "pages"
9
+ },
10
+ "author": "Cloud Bongos LLC",
11
+ "origin": "Cloud Bongos (cloudbongos.com)",
12
+ "license": "AGPL-3.0",
13
+ "maintainer": "Cloud Bongos maintainers",
14
+ "default": false,
15
+ "maintenance": {
16
+ "status": "maintained"
17
+ },
18
+ "migrations": true,
19
+ "contributes": {
20
+ "routes": [
21
+ "front-door-guide"
22
+ ]
23
+ },
24
+ "provides": [],
25
+ "consumes": [],
26
+ "dependencies": { "@anthropic-ai/sdk": "^0.131.0" }
27
+ }
@@ -0,0 +1,206 @@
1
+ // modules/front-door-guide/routes/front-door-guide.js — the start-a-project guide's HTTP
2
+ // surface (task 1005272). A signed-out visitor talks with an AI guide about what they
3
+ // want to build before signing up.
4
+ //
5
+ // GET /front-door-guide/status can the guide talk right now
6
+ // POST /front-door-guide/conversations start a chat { website? }
7
+ // POST /front-door-guide/conversations/:id/messages send one message { token, text, website? }
8
+ // GET /front-door-guide/conversations/:id read it back header x-guide-token
9
+ //
10
+ // rank: public — every route. The visitor has no account yet; that is the point. What
11
+ // stands in for one: per-visitor rate limits, a bot check, a token per conversation, and
12
+ // every money cap enforced here before a call is made (the per-visitor message count and
13
+ // spend, the site's daily and monthly budget). A spent budget or a missing key answers
14
+ // `mode: 'form'`, never an error, so the front end always has somewhere to send the
15
+ // visitor. The response shape is documented in ../CLAUDE.md.
16
+ //
17
+ // The visitor's words are never logged.
18
+
19
+ 'use strict';
20
+
21
+ const express = require('express');
22
+ const api = require('../../../src/module-api');
23
+ const G = require('../guide');
24
+ const db = require('../db');
25
+ const claude = require('../claude');
26
+ const { readSettings } = require('../settings');
27
+ const L = require('../limits');
28
+
29
+ const log = api.logger('front-door-guide');
30
+ const ID_RE = /^\d{1,18}$/;
31
+ const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
32
+
33
+ function salt() {
34
+ let s;
35
+ try { s = api.resolveEnv('FRONT_DOOR_GUIDE_SALT'); } catch (_) { s = null; }
36
+ if (s) return s;
37
+ let name = 'bongos';
38
+ try { name = api.instanceDbName() || name; } catch (_) { /* the default salt still separates visitors */ }
39
+ return `front-door-guide:${name}`;
40
+ }
41
+
42
+ function productName() {
43
+ try { return api.branding().identity.productName || 'Cloud Bongos'; } catch (_) { return 'Cloud Bongos'; }
44
+ }
45
+
46
+ // Can the site afford a call of `extraMicro` right now? → null, or the form reason.
47
+ async function siteBudgetReason(settings, extraMicro, reservations) {
48
+ const pending = reservations.siteMicro();
49
+ // Spent is spent (>=), and a call must also fit in what is left (>).
50
+ const over = (spent, cap) => spent + pending >= cap || spent + pending + extraMicro > cap;
51
+ if (over(await db.siteSpendMicro('day'), settings.dailyBudgetMicro)) return 'daily_budget';
52
+ if (over(await db.siteSpendMicro('month'), settings.monthlyBudgetMicro)) return 'monthly_budget';
53
+ return null;
54
+ }
55
+
56
+ module.exports = function buildFrontDoorGuideRouter() {
57
+ const router = express.Router();
58
+ const reservations = L.makeReservations();
59
+ let current = readSettings();
60
+ const knobs = () => current;
61
+
62
+ const statusLimit = L.makeLimiter({ windowMs: 60_000, max: () => 60, scope: 'front-door-guide-read' });
63
+ const startLimit = L.makeLimiter({ windowMs: 3_600_000, max: () => knobs().startsPerHour, scope: 'front-door-guide-start' });
64
+ const messageLimit = L.makeLimiter({ windowMs: 60_000, max: () => knobs().messagesPerMinute, scope: 'front-door-guide-message' });
65
+ // Settings are re-read on every request, so an owner's change applies without a restart.
66
+ const fresh = (req, res, next) => { current = readSettings(); next(); };
67
+
68
+ // rank: public — the landing asks before it offers the chat.
69
+ router.get('/front-door-guide/status', fresh, statusLimit, api.asyncHandler('GET /front-door-guide/status', async (req, res) => {
70
+ const s = knobs();
71
+ const reason = !s.apiKey ? 'no_key' : await siteBudgetReason(s, 0, reservations);
72
+ res.json({
73
+ ok: true,
74
+ mode: reason ? 'form' : 'chat',
75
+ ...(reason ? { reason, message: G.FORM_REASONS[reason] } : {}),
76
+ limits: { max_messages: s.maxMessages, max_message_chars: s.maxMessageChars },
77
+ });
78
+ }));
79
+
80
+ // rank: public — start a chat. Answers the greeting and the conversation's token.
81
+ router.post('/front-door-guide/conversations', fresh, startLimit, api.asyncHandler('POST /front-door-guide/conversations', async (req, res) => {
82
+ if (api.validateOrRespond(req, res, { website: { type: 'string', maxLength: 500 } })) return;
83
+ const s = knobs();
84
+ const body = req.body || {};
85
+ if (G.botCheck({ body, userAgent: req.get('user-agent'), now: Date.now(), minFirstMessageMs: s.minFirstMessageMs })) {
86
+ return res.json(G.formAnswer('check_failed'));
87
+ }
88
+ if (!s.apiKey) return res.json(G.formAnswer('no_key'));
89
+ const budget = await siteBudgetReason(s, 0, reservations);
90
+ if (budget) return res.json(G.formAnswer(budget));
91
+ const visitor = L.visitorHash(L.clientIp(req), salt());
92
+ const used = await db.visitorUsage(visitor);
93
+ if (used.messages >= s.maxMessages || used.spendMicro >= s.visitorCapMicro) return res.json(G.formAnswer('visitor_limit'));
94
+ const token = L.newToken();
95
+ const conv = await db.createConversation(L.tokenHash(token), visitor);
96
+ res.status(201).json({
97
+ ok: true, mode: 'chat',
98
+ conversation: { ...G.conversationView(conv, s), token },
99
+ greeting: G.GREETING,
100
+ });
101
+ }));
102
+
103
+ // rank: public — one visitor message, one guide reply.
104
+ router.post('/front-door-guide/conversations/:id/messages', fresh, messageLimit, api.asyncHandler('POST /front-door-guide/conversations/:id/messages', async (req, res) => {
105
+ // The schema's ceiling on `text` is loose on purpose: the owner's own length limit is
106
+ // checked below, so a visitor hears `too_long` with the number, not a generic refusal.
107
+ if (api.validateOrRespond(req, res, {
108
+ token: { required: true, type: 'string', maxLength: 64 },
109
+ text: { required: true, type: 'string', maxLength: 100_000 },
110
+ website: { type: 'string', maxLength: 500 },
111
+ })) return;
112
+ const s = knobs();
113
+ const body = req.body || {};
114
+ if (!ID_RE.test(String(req.params.id)) || typeof body.token !== 'string' || !TOKEN_RE.test(body.token)) {
115
+ return res.status(400).json({ ok: false, error: 'bad_request' });
116
+ }
117
+ if (typeof body.text !== 'string' || !body.text.trim()) return res.status(400).json({ ok: false, error: 'empty' });
118
+ const text = body.text.trim();
119
+ if (text.length > s.maxMessageChars) {
120
+ return res.status(400).json({ ok: false, error: 'too_long', max_message_chars: s.maxMessageChars });
121
+ }
122
+ const conv = await db.getConversation(req.params.id, L.tokenHash(body.token));
123
+ if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
124
+ const view = () => G.conversationView(conv, s);
125
+ const ended = (reason) => ({ ok: true, mode: 'ended', reason, message: G.END_REASONS[reason] || G.END_REASONS.message_cap, conversation: view() });
126
+ if (conv.status === 'ended') return res.json(ended(conv.end_reason));
127
+
128
+ const bot = G.botCheck({
129
+ body, userAgent: req.get('user-agent'), now: Date.now(), minFirstMessageMs: s.minFirstMessageMs,
130
+ startedAt: new Date(conv.created_at).getTime(), firstMessage: Number(conv.visitor_messages) === 0,
131
+ });
132
+ if (bot) return res.json(G.formAnswer('check_failed'));
133
+ if (!s.apiKey) return res.json(G.formAnswer('no_key'));
134
+
135
+ // The visitor's caps count every chat they started today, not just this one.
136
+ const visitor = conv.visitor_hash;
137
+ if (reservations.busy(visitor)) return res.status(429).json({ ok: false, error: 'busy', retry_after_seconds: 2 });
138
+ const used = await db.visitorUsage(visitor);
139
+ if (used.messages >= s.maxMessages || Number(conv.visitor_messages) >= s.maxMessages) {
140
+ await db.endConversation(conv.id, 'message_cap');
141
+ return res.json(ended('message_cap'));
142
+ }
143
+
144
+ const system = G.systemPrompt(productName());
145
+ const messages = [...(conv.messages || []), { role: 'user', content: text }];
146
+ const estimate = G.estimateMicro({ model: s.model, system, messages, maxReplyTokens: s.maxReplyTokens });
147
+ if (used.spendMicro + estimate > s.visitorCapMicro) {
148
+ await db.endConversation(conv.id, 'visitor_budget');
149
+ return res.json(ended('visitor_budget'));
150
+ }
151
+ const budget = await siteBudgetReason(s, estimate, reservations);
152
+ if (budget) return res.json(G.formAnswer(budget));
153
+
154
+ const release = reservations.take(visitor, estimate);
155
+ if (!release) return res.status(429).json({ ok: false, error: 'busy', retry_after_seconds: 2 });
156
+ let result;
157
+ try {
158
+ result = await claude.askGuide({ client: claude.createClient(s.apiKey), settings: s, system, messages });
159
+ } finally {
160
+ release();
161
+ }
162
+ if (!result.ok) {
163
+ log.warn({ conversation_id: String(conv.id), error: result.error }, 'front-door-guide: call failed');
164
+ return res.json(G.formAnswer('unavailable'));
165
+ }
166
+
167
+ const cost = G.costMicro(result.model, result.usage);
168
+ const refused = result.stopReason === 'refusal';
169
+ const reply = refused ? G.REFUSAL_REPLY : (G.replyText(result.content) || G.EMPTY_REPLY);
170
+ // A declined message is not sent again on the next turn, or it would be declined again.
171
+ const kept = refused ? [...(conv.messages || []), { role: 'user', content: '(The visitor\'s message was declined.)' }] : messages;
172
+ const nextMessages = [...kept, { role: 'assistant', content: reply }];
173
+ let endReason = null;
174
+ if (Number(conv.visitor_messages) + 1 >= s.maxMessages || used.messages + 1 >= s.maxMessages) endReason = 'message_cap';
175
+ else if (used.spendMicro + cost >= s.visitorCapMicro) endReason = 'visitor_budget';
176
+ const u = result.usage || {};
177
+ const saved = await db.recordTurn(conv.id, { messages: nextMessages, status: endReason ? 'ended' : 'open', endReason }, {
178
+ visitorHash: visitor, model: result.model, costMicro: cost, stopReason: result.stopReason || null,
179
+ inputTokens: Number(u.input_tokens) || 0, outputTokens: Number(u.output_tokens) || 0,
180
+ cacheReadTokens: Number(u.cache_read_input_tokens) || 0, cacheWriteTokens: Number(u.cache_creation_input_tokens) || 0,
181
+ });
182
+ res.json({
183
+ ok: true,
184
+ mode: endReason ? 'ended' : 'chat',
185
+ ...(endReason ? { reason: endReason, message: G.END_REASONS[endReason] } : {}),
186
+ ...(refused ? { declined: true } : {}),
187
+ reply: { text: reply },
188
+ conversation: G.conversationView(saved || { ...conv, visitor_messages: Number(conv.visitor_messages) + 1 }, s),
189
+ });
190
+ }));
191
+
192
+ // rank: public — the transcript, for the plan and the sign-up hand-off.
193
+ router.get('/front-door-guide/conversations/:id', fresh, statusLimit, api.asyncHandler('GET /front-door-guide/conversations/:id', async (req, res) => {
194
+ const token = String(req.get('x-guide-token') || '');
195
+ if (!ID_RE.test(String(req.params.id)) || !TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'bad_request' });
196
+ const conv = await db.getConversation(req.params.id, L.tokenHash(token));
197
+ if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
198
+ res.json({
199
+ ok: true,
200
+ conversation: { ...G.conversationView(conv, knobs()), status: conv.status, end_reason: conv.end_reason || null },
201
+ transcript: G.transcriptView(conv.messages),
202
+ });
203
+ }));
204
+
205
+ return router;
206
+ };
@@ -0,0 +1,68 @@
1
+ // modules/front-door-guide/settings.js — the owner's knobs for the start-a-project
2
+ // guide (task 1005272): which model answers, how many messages and how much money one
3
+ // visitor may use, the site's daily and monthly budget, and the per-message length.
4
+ //
5
+ // Every knob is configuration, read from the environment under the instance's prefix
6
+ // (BONGOS_FRONT_DOOR_GUIDE_<NAME>, via the doorway's resolveEnv), and DEFAULTS below is
7
+ // the one place the starting values are written. A value that is missing, blank or not
8
+ // a usable number falls back to its default, so a typo never opens a cap wider.
9
+ //
10
+ // Money is integer micro-dollars (1 dollar = 1,000,000), the ai-studio convention, so
11
+ // no budget comparison ever rounds a float. The env takes cents, which owners read.
12
+
13
+ 'use strict';
14
+
15
+ // The starting values: the owner's settings for testing (2026-11-13).
16
+ const DEFAULTS = Object.freeze({
17
+ MODEL: 'claude-opus-5-5',
18
+ EFFORT: 'low', // a short planning chat, not hard reasoning
19
+ MAX_MESSAGES: 8, // visitor messages per visitor per day
20
+ VISITOR_CAP_CENTS: 50, // what one visitor may spend per day
21
+ DAILY_BUDGET_CENTS: 200, // the whole site, per UTC day
22
+ MONTHLY_BUDGET_CENTS: 2500, // the whole site, per UTC calendar month
23
+ MAX_MESSAGE_CHARS: 1000, // one visitor message
24
+ MAX_REPLY_TOKENS: 1500, // one guide reply, thinking included
25
+ STARTS_PER_HOUR: 5, // new conversations per visitor per hour
26
+ MESSAGES_PER_MINUTE: 6, // messages per visitor per minute
27
+ MIN_FIRST_MESSAGE_MS: 1500, // a person takes longer than this to type a first line
28
+ });
29
+
30
+ const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
31
+ const MODEL_RE = /^claude-[a-z0-9.-]{1,60}$/;
32
+
33
+ function defaultResolve(suffix) {
34
+ return require('../../src/module-api').resolveEnv(suffix);
35
+ }
36
+
37
+ // A positive whole number from the env, or the default.
38
+ function intKnob(read, name) {
39
+ const raw = read(`FRONT_DOOR_GUIDE_${name}`);
40
+ const n = Number(raw);
41
+ return raw !== undefined && raw !== '' && Number.isInteger(n) && n > 0 ? n : DEFAULTS[name];
42
+ }
43
+
44
+ // readSettings({ resolve }) → the resolved knobs, money in micro-dollars, plus the key.
45
+ // The key is read here and only handed to the client builder; nothing returns it to a
46
+ // browser. `resolve` is injectable so a test needs no environment.
47
+ function readSettings({ resolve = defaultResolve } = {}) {
48
+ const read = (s) => { try { return resolve(s); } catch (_) { return undefined; } };
49
+ const model = String(read('FRONT_DOOR_GUIDE_MODEL') || '').trim();
50
+ const effort = String(read('FRONT_DOOR_GUIDE_EFFORT') || '').trim();
51
+ const key = String(read('FRONT_DOOR_GUIDE_ANTHROPIC_API_KEY') || '').trim();
52
+ return {
53
+ model: MODEL_RE.test(model) ? model : DEFAULTS.MODEL,
54
+ effort: EFFORTS.has(effort) ? effort : DEFAULTS.EFFORT,
55
+ apiKey: key || null,
56
+ maxMessages: intKnob(read, 'MAX_MESSAGES'),
57
+ visitorCapMicro: intKnob(read, 'VISITOR_CAP_CENTS') * 10_000,
58
+ dailyBudgetMicro: intKnob(read, 'DAILY_BUDGET_CENTS') * 10_000,
59
+ monthlyBudgetMicro: intKnob(read, 'MONTHLY_BUDGET_CENTS') * 10_000,
60
+ maxMessageChars: intKnob(read, 'MAX_MESSAGE_CHARS'),
61
+ maxReplyTokens: intKnob(read, 'MAX_REPLY_TOKENS'),
62
+ startsPerHour: intKnob(read, 'STARTS_PER_HOUR'),
63
+ messagesPerMinute: intKnob(read, 'MESSAGES_PER_MINUTE'),
64
+ minFirstMessageMs: intKnob(read, 'MIN_FIRST_MESSAGE_MS'),
65
+ };
66
+ }
67
+
68
+ module.exports = { readSettings, DEFAULTS };
@@ -82,7 +82,9 @@ const ALWAYS_ON_CORE = Object.freeze([
82
82
  // bills AI generation through Cloud Bongos's own payment processor account, so it stays on
83
83
  // the hub until a project can bring its own billing. `market-watch` sat here while it was
84
84
  // empty; task 1004938 shipped the owner's on/off switch and moved it to OPTIONAL_MODULES.
85
- const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy', 'raise', 'ai-studio']);
85
+ // `front-door-guide` (task 1005272) is the hub's start-a-project chat on Cloud Bongos's own
86
+ // Anthropic key, so a new project gains nothing by switching it on.
87
+ const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy', 'raise', 'ai-studio', 'front-door-guide']);
86
88
 
87
89
  // The OPT-IN catalog as the creation picker renders it (BV1.R20, task 1002339):
88
90
  // every module an owner may actually choose, in the order the picker lists them,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.11",
3
+ "version": "1.25.12",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.11",
9
+ "version": "1.25.12",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.11",
3
+ "version": "1.25.12",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11772,5 +11772,15 @@
11772
11772
  "id": "1005268",
11773
11773
  "text": "An Archon can now switch the card requirement on or off for a builder straight from the People roster."
11774
11774
  }
11775
+ ],
11776
+ "1.25.12": [
11777
+ {
11778
+ "id": "1005272",
11779
+ "text": "Visitors who press start a project can now talk with an AI guide about what they want to build, with strict limits on messages and spending, and a plain form whenever the guide is unavailable."
11780
+ },
11781
+ {
11782
+ "id": "1005288",
11783
+ "text": "Decided how every project gets a sandbox: a free copy of the app that always shows the newest finished work, while the real site only changes when a person presses promote. Fake data and test keys by default. Costs the owner n"
11784
+ }
11775
11785
  ]
11776
11786
  }
@@ -78,6 +78,7 @@ const PUBLISH_ALLOWLIST = [
78
78
  'modules/render-deploy/', // the deploy page of a project whose app deploys through Render (task 1004270). default:false. MUST be here for the npm-release reason: modules/hall-ui/ ships as one subtree, so a released core without it carries deploy.js's loader for #rd-panel with no routes behind it.
79
79
  'modules/market-watch/', // the weekly AI market watch (task 1004935, goal 1000137). default:false and empty for now, but MUST be here: shipped is not enabled, and a released core without it answers "unknown module" to an instance that turns it on; tests/market_watch_module.mjs (publishable) also reads its manifest.
80
80
  'modules/ai-studio/', // AI generation for artists (task 1004916, ADR 0392): keys, prepaid balance, generate. default:false.
81
+ 'modules/front-door-guide/', // the start-a-project guide (task 1005272): a capped AI chat for a signed-out visitor on the platform's key. default:false, hub first; shipped is not enabled.
81
82
  'modules/raise/', // the /raise investor page (task 1004847, ADR 0384) — ships in core, enabled ONLY on the hub, like platform-identity. Owner decision 2026-10-25: the whole page, its copy and starting numbers included, lives in the repo so builders can contribute to it, accepting that it publishes with the core. Secrets never do: the investor password is RAISE_PASSWORD_HASH in the instance env. default:false.
82
83
  'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
83
84
  // --- neutral (customer-0) config starters + their schemas (the filled-in *.json twins are denied) ---
@@ -186,6 +186,11 @@ const MODULE_GLOBS = {
186
186
  'ai-studio': [
187
187
  'modules/ai-studio/',
188
188
  ],
189
+ // The start-a-project guide (task 1005272, goal 1000106): a capped AI chat for a
190
+ // signed-out visitor. Its test sits at the repo root, listed by exact path.
191
+ 'front-door-guide': [
192
+ 'modules/front-door-guide/', 'tests/front_door_guide.mjs',
193
+ ],
189
194
  // The /raise investor page (task 1004847, ADR 0384): hub-only.
190
195
  raise: [
191
196
  'modules/raise/',
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.11'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.12'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');