mojulo 0.0.0 → 0.1.0

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 (121) hide show
  1. package/README.md +53 -4
  2. package/lib/audit-logger-new.js +11 -0
  3. package/lib/auth/gate.js +25 -0
  4. package/lib/auth/service.js +17 -0
  5. package/lib/auth/session.js +63 -0
  6. package/lib/builder/chat-processor.js +607 -0
  7. package/lib/builder/composer-bridge.js +82 -0
  8. package/lib/builder/evaluator.js +159 -0
  9. package/lib/builder/executor.js +252 -0
  10. package/lib/builder/index.js +48 -0
  11. package/lib/builder/session.js +248 -0
  12. package/lib/builder/system-prompt.js +422 -0
  13. package/lib/builder/tone-presets.js +75 -0
  14. package/lib/builder/tool-executors.js +1418 -0
  15. package/lib/builder/tools.js +338 -0
  16. package/lib/builder/validators.js +239 -0
  17. package/lib/composer/composer.js +225 -0
  18. package/lib/composer/index.js +40 -0
  19. package/lib/composer/protocols/00_base.txt +19 -0
  20. package/lib/composer/protocols/01_knowledge.txt +9 -0
  21. package/lib/composer/protocols/02_form-gathering.txt +32 -0
  22. package/lib/composer/protocols/03_appointments.txt +16 -0
  23. package/lib/composer/protocols/04_triage.txt +15 -0
  24. package/lib/composer/protocols/05_optical-read.txt +22 -0
  25. package/lib/composer/response-builder.js +98 -0
  26. package/lib/config-builder.js +650 -0
  27. package/lib/db/ids.js +10 -0
  28. package/lib/db/index.js +179 -0
  29. package/lib/db/repositories/apiKeys.js +72 -0
  30. package/lib/db/repositories/auditLogs.js +12 -0
  31. package/lib/db/repositories/botSpaces.js +12 -0
  32. package/lib/db/repositories/builderSessions.js +312 -0
  33. package/lib/db/repositories/deploymentEvents.js +12 -0
  34. package/lib/db/repositories/deployments.js +385 -0
  35. package/lib/db/repositories/documents.js +68 -0
  36. package/lib/db/repositories/mcpJobs.js +84 -0
  37. package/lib/deployers/bot-fleet.js +110 -0
  38. package/lib/deployers/bot-proxy.js +72 -0
  39. package/lib/deployers/build.js +89 -0
  40. package/lib/deployers/cloud-deploy.js +310 -0
  41. package/lib/deployers/docker.js +439 -0
  42. package/lib/deployers/fly.js +432 -0
  43. package/lib/deployers/index.js +38 -0
  44. package/lib/deployment-auth.js +36 -0
  45. package/lib/document-parser.js +171 -0
  46. package/lib/embedder/chunker.js +93 -0
  47. package/lib/embedder/local.js +101 -0
  48. package/lib/embedder/preview-rag.js +93 -0
  49. package/lib/envelope-schema.js +54 -0
  50. package/lib/fleet/scoped-sql.js +342 -0
  51. package/lib/form-schema-config/base.js +135 -0
  52. package/lib/form-schema-config/index.js +286 -0
  53. package/lib/form-schema-config/locales/af-ZA.js +153 -0
  54. package/lib/form-schema-config/locales/ar-AE.js +142 -0
  55. package/lib/form-schema-config/locales/ar-SA.js +164 -0
  56. package/lib/form-schema-config/locales/de-DE.js +152 -0
  57. package/lib/form-schema-config/locales/en-AU.js +161 -0
  58. package/lib/form-schema-config/locales/en-CA.js +115 -0
  59. package/lib/form-schema-config/locales/en-GB.js +132 -0
  60. package/lib/form-schema-config/locales/en-IN.js +219 -0
  61. package/lib/form-schema-config/locales/en-MY.js +171 -0
  62. package/lib/form-schema-config/locales/en-NG.js +198 -0
  63. package/lib/form-schema-config/locales/en-PH.js +186 -0
  64. package/lib/form-schema-config/locales/en-SG.js +153 -0
  65. package/lib/form-schema-config/locales/en-US.js +138 -0
  66. package/lib/form-schema-config/locales/es-ES.js +171 -0
  67. package/lib/form-schema-config/locales/es-MX.js +193 -0
  68. package/lib/form-schema-config/locales/fr-CA.js +138 -0
  69. package/lib/form-schema-config/locales/fr-FR.js +155 -0
  70. package/lib/form-schema-config/locales/hi-IN.js +219 -0
  71. package/lib/form-schema-config/locales/it-IT.js +157 -0
  72. package/lib/form-schema-config/locales/ja-JP.js +169 -0
  73. package/lib/form-schema-config/locales/ko-KR.js +140 -0
  74. package/lib/form-schema-config/locales/nl-NL.js +149 -0
  75. package/lib/form-schema-config/locales/pt-BR.js +168 -0
  76. package/lib/form-schema-config/locales/zh-CN.js +172 -0
  77. package/lib/form-schema-config/locales/zh-HK.js +142 -0
  78. package/lib/form-structure-schema.js +191 -0
  79. package/lib/llm-providers.js +828 -0
  80. package/lib/markdown.js +197 -0
  81. package/lib/mcp/catalysts/appointment-to-calendar.md +84 -0
  82. package/lib/mcp/catalysts/conversations-to-channel-digest.md +104 -0
  83. package/lib/mcp/catalysts/document-extract-to-store.md +92 -0
  84. package/lib/mcp/catalysts/knowledge-gap-miner.md +96 -0
  85. package/lib/mcp/catalysts/loader.js +144 -0
  86. package/lib/mcp/catalysts/qualify-lead-to-crm.md +83 -0
  87. package/lib/mcp/catalysts/scan-conversations-for-signal.md +92 -0
  88. package/lib/mcp/catalysts/submission-to-ticket.md +83 -0
  89. package/lib/mcp/catalysts/submissions-to-warehouse.md +103 -0
  90. package/lib/mcp/catalysts/weekly-submissions-digest.md +82 -0
  91. package/lib/mcp/jobs.js +64 -0
  92. package/lib/mcp/server.js +184 -0
  93. package/lib/mcp/session-binding.js +130 -0
  94. package/lib/mcp/tools/build.js +123 -0
  95. package/lib/mcp/tools/catalysts.js +477 -0
  96. package/lib/mcp/tools/context.js +325 -0
  97. package/lib/mcp/tools/fleet.js +391 -0
  98. package/lib/mcp/tools/jobs-tools.js +240 -0
  99. package/lib/mcp/tools/operate.js +314 -0
  100. package/lib/preview/build-preview-config.js +136 -0
  101. package/lib/rate-limiter.js +11 -0
  102. package/lib/resolve-api-key.js +142 -0
  103. package/lib/storage/index.js +40 -0
  104. package/messages/de.json +2136 -0
  105. package/messages/en.json +2136 -0
  106. package/messages/es.json +2136 -0
  107. package/messages/fr.json +2136 -0
  108. package/messages/it.json +2136 -0
  109. package/messages/ja.json +2136 -0
  110. package/messages/ko.json +2136 -0
  111. package/messages/nl.json +2136 -0
  112. package/messages/pl.json +2136 -0
  113. package/messages/pt.json +2136 -0
  114. package/messages/ru.json +2136 -0
  115. package/messages/uk.json +2136 -0
  116. package/messages/zh.json +2136 -0
  117. package/package.json +61 -5
  118. package/scripts/mcp-config.mjs +162 -0
  119. package/scripts/mcp-stdio-loader.mjs +42 -0
  120. package/scripts/mcp-stdio.mjs +108 -0
  121. package/scripts/mojulo-paths.mjs +48 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * MCP Ring 2 — operate / read tools.
3
+ *
4
+ * Lets the user's Claude reason over deployed bot state without exposing new
5
+ * HTTP endpoints from the control plane. Reads either hit local SQLite
6
+ * (deployment metadata) or pass through bot-proxy to the bot's own SQLite
7
+ * (conversations, submissions, chain verification) — preserving the
8
+ * "conversation data never leaves the bot's SQLite" invariant.
9
+ */
10
+
11
+ import { DeploymentRepository } from '@/lib/db/repositories/deployments';
12
+ import { fetchFromBot } from '@/lib/deployers/bot-proxy';
13
+ import { registerTool } from '@/lib/mcp/server';
14
+
15
+ function summarizeDeployment(d) {
16
+ if (!d) return null;
17
+ return {
18
+ id: d.id,
19
+ botName: d.botName,
20
+ status: d.status,
21
+ url: d.url || null,
22
+ lastSeenAt: d.lastSeenAt ? d.lastSeenAt.toISOString() : null,
23
+ configHash: d.configHash || null,
24
+ lastBuiltHash: d.lastBuiltHash || null,
25
+ ragMode: d.ragMode,
26
+ embeddingChunkCount: d.embeddingChunkCount,
27
+ cloud: d.cloudProvider
28
+ ? {
29
+ provider: d.cloudProvider,
30
+ status: d.cloudStatus,
31
+ url: d.cloudUrl,
32
+ appName: d.cloudAppName,
33
+ lastDeployedAt: d.cloudLastDeployedAt ? d.cloudLastDeployedAt.toISOString() : null,
34
+ }
35
+ : null,
36
+ createdAt: d.createdAt.toISOString(),
37
+ updatedAt: d.updatedAt.toISOString(),
38
+ };
39
+ }
40
+
41
+ async function listDeploymentsHandler(input, _ctx) {
42
+ const { status, mode, limit = 50, offset = 0 } = input || {};
43
+ const all = await DeploymentRepository.list();
44
+ let filtered = all;
45
+ if (status) {
46
+ filtered = filtered.filter((d) => d.status === status);
47
+ }
48
+ if (mode === 'cloud') {
49
+ filtered = filtered.filter((d) => !!d.cloudProvider);
50
+ } else if (mode === 'local') {
51
+ filtered = filtered.filter((d) => !d.cloudProvider);
52
+ }
53
+ const total = filtered.length;
54
+ const page = filtered.slice(offset, offset + limit).map(summarizeDeployment);
55
+ return { total, limit, offset, deployments: page };
56
+ }
57
+
58
+ function redactConfigCredentials(config) {
59
+ if (!config || typeof config !== 'object') return config;
60
+ const llmConfig = config.llmConfig;
61
+ if (!llmConfig || typeof llmConfig !== 'object') return config;
62
+ const redactedLlm = {};
63
+ for (const [provider, providerConfig] of Object.entries(llmConfig)) {
64
+ if (!providerConfig || typeof providerConfig !== 'object') {
65
+ redactedLlm[provider] = providerConfig;
66
+ continue;
67
+ }
68
+ const next = { ...providerConfig };
69
+ if ('apiKey' in next) next.apiKey = next.apiKey ? '[redacted]' : '';
70
+ if ('accessKeyId' in next && next.accessKeyId) next.accessKeyId = '[redacted]';
71
+ if ('secretAccessKey' in next && next.secretAccessKey) next.secretAccessKey = '[redacted]';
72
+ redactedLlm[provider] = next;
73
+ }
74
+ return { ...config, llmConfig: redactedLlm };
75
+ }
76
+
77
+ async function getDeploymentHandler(input, _ctx) {
78
+ const { id } = input || {};
79
+ if (!id) throw new Error('id is required');
80
+ const dep = await DeploymentRepository.findById(id);
81
+ if (!dep) throw new Error(`Deployment not found: ${id}`);
82
+ const summary = summarizeDeployment(dep);
83
+ return {
84
+ ...summary,
85
+ config: redactConfigCredentials(dep.config),
86
+ botSummary: dep.config?.botSummary || null,
87
+ documentIds: dep.documentIds,
88
+ };
89
+ }
90
+
91
+ async function loadConnectedDeployment(id) {
92
+ if (!id) throw new Error('id is required');
93
+ const dep = await DeploymentRepository.findById(id);
94
+ if (!dep) throw new Error(`Deployment not found: ${id}`);
95
+ if (!dep.url) throw new Error(`Deployment ${id} has no URL — bot is not connected`);
96
+ return dep;
97
+ }
98
+
99
+ async function proxyJson(dep, path, opts) {
100
+ let response;
101
+ try {
102
+ response = await fetchFromBot(dep, path, opts);
103
+ } catch (err) {
104
+ throw new Error(`Could not reach bot: ${err.message || err.name}`);
105
+ }
106
+ if (!response.ok) {
107
+ const text = await response.text().catch(() => '');
108
+ throw new Error(
109
+ `Bot returned ${response.status}: ${text.slice(0, 200)}`
110
+ );
111
+ }
112
+ await DeploymentRepository.touchLastSeen(dep.id).catch(() => {});
113
+ return response.json();
114
+ }
115
+
116
+ // Both list and full-dump flows hit /api/conversations/export rather than
117
+ // /api/conversations. The list endpoint has a guardrail that returns 0 rows
118
+ // unless conversationId / startDate / endDate is provided, which surfaced as
119
+ // "0 results but the bot clearly has conversations" through MCP.
120
+ async function fetchExport(dep, { startDate, endDate }) {
121
+ const qs = new URLSearchParams();
122
+ if (startDate) qs.set('startDate', String(startDate));
123
+ if (endDate) qs.set('endDate', String(endDate));
124
+ const path = `/api/conversations/export${qs.toString() ? `?${qs.toString()}` : ''}`;
125
+ return proxyJson(dep, path, { timeoutMs: 60000 });
126
+ }
127
+
128
+ async function queryConversationsHandler(input, _ctx) {
129
+ const { id, since, until } = input || {};
130
+ const dep = await loadConnectedDeployment(id);
131
+ const data = await fetchExport(dep, { startDate: since, endDate: until });
132
+ const conversations = (Array.isArray(data) ? data : []).map((c) => ({
133
+ conversationId: c.conversationId,
134
+ startedAt: c.startedAt,
135
+ lastActivity: c.lastActivity,
136
+ turnCount: c.turnCount,
137
+ }));
138
+ return { botName: dep.botName, total: conversations.length, conversations };
139
+ }
140
+
141
+ // The bot's /api/conversations/:id endpoint emits raw snake_case columns, while
142
+ // /api/conversations/export hand-maps to camelCase. Normalize the by-id turn
143
+ // shape so consumers see one casing across both tools.
144
+ function normalizeTurn(t) {
145
+ if (!t || typeof t !== 'object') return t;
146
+ return {
147
+ id: t.id,
148
+ conversationId: t.conversation_id,
149
+ turn: t.turn,
150
+ timestamp: t.timestamp,
151
+ userPrompt: t.user_prompt,
152
+ llmResponse: t.llm_response,
153
+ machineState: t.machine_state,
154
+ ragContext: t.rag_context,
155
+ contentHash: t.content_hash,
156
+ chainHash: t.chain_hash,
157
+ eventType: t.event_type,
158
+ handoffHash: t.handoff_hash,
159
+ };
160
+ }
161
+
162
+ async function getConversationHandler(input, _ctx) {
163
+ const { id, conversationId } = input || {};
164
+ if (!conversationId) throw new Error('conversationId is required');
165
+ const dep = await loadConnectedDeployment(id);
166
+ const data = await proxyJson(dep, `/api/conversations/${encodeURIComponent(conversationId)}`);
167
+ return {
168
+ ...data,
169
+ turns: Array.isArray(data?.turns) ? data.turns.map(normalizeTurn) : data?.turns,
170
+ };
171
+ }
172
+
173
+ async function exportConversationsHandler(input, _ctx) {
174
+ const { id, startDate, endDate } = input || {};
175
+ const dep = await loadConnectedDeployment(id);
176
+ const data = await fetchExport(dep, { startDate, endDate });
177
+ return { botName: dep.botName, conversations: data };
178
+ }
179
+
180
+ async function querySubmissionsHandler(input, _ctx) {
181
+ const { id, limit, offset, since, until } = input || {};
182
+ const dep = await loadConnectedDeployment(id);
183
+ const qs = new URLSearchParams();
184
+ if (limit != null) qs.set('limit', String(limit));
185
+ if (offset != null) qs.set('offset', String(offset));
186
+ if (since) qs.set('since', String(since));
187
+ if (until) qs.set('until', String(until));
188
+ const path = `/api/forms${qs.toString() ? `?${qs.toString()}` : ''}`;
189
+ const data = await proxyJson(dep, path);
190
+ return { botName: dep.botName, ...data };
191
+ }
192
+
193
+ async function verifyChainHandler(input, _ctx) {
194
+ const { id, conversationId } = input || {};
195
+ if (!conversationId) throw new Error('conversationId is required');
196
+ const dep = await loadConnectedDeployment(id);
197
+ return proxyJson(dep, `/verify/${encodeURIComponent(conversationId)}`);
198
+ }
199
+
200
+ export function registerOperateTools() {
201
+ // Deployment metadata — always available. No transcript data, just rows
202
+ // from the control plane's deployments table.
203
+ registerTool({
204
+ name: 'list_deployments',
205
+ description:
206
+ 'List bots known to the control plane. Returns id, name, status, URL, connection state, and cloud metadata. Filter by status (saved | building | ready | stale | build_failed) or mode (local | cloud).',
207
+ inputSchema: {
208
+ type: 'object',
209
+ properties: {
210
+ status: { type: 'string', description: 'Filter by deployment status.' },
211
+ mode: { type: 'string', enum: ['local', 'cloud'], description: 'Filter by local-only or cloud-deployed bots.' },
212
+ limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 },
213
+ offset: { type: 'integer', minimum: 0, default: 0 },
214
+ },
215
+ },
216
+ handler: listDeploymentsHandler,
217
+ });
218
+
219
+ registerTool({
220
+ name: 'get_deployment',
221
+ description:
222
+ 'Get the full deployment row for one bot: identity, enabled protocols, generated configs, document ids, and cloud state. Reads from the control plane SQLite only — no proxy to the bot.',
223
+ inputSchema: {
224
+ type: 'object',
225
+ properties: {
226
+ id: { type: 'string', description: 'Deployment id.' },
227
+ },
228
+ required: ['id'],
229
+ },
230
+ handler: getDeploymentHandler,
231
+ });
232
+
233
+ // Transcript-touching tools. The proxy reads keep conversation data inside
234
+ // the bot's SQLite — the model sees them but the control-plane DB never does.
235
+ registerTool({
236
+ name: 'query_conversations',
237
+ description:
238
+ 'List conversation summaries on a connected bot (one entry per conversation — id, started_at, last_activity, turn_count). Proxies through to the bot — conversation rows live in the bot SQLite, never the control plane. Optional since / until ISO bounds filter on the first-turn timestamp. Use get_conversation for the full turn list, or export_conversations to pull every turn in one shot.',
239
+ inputSchema: {
240
+ type: 'object',
241
+ properties: {
242
+ id: { type: 'string', description: 'Deployment id.' },
243
+ since: { type: 'string', description: 'ISO timestamp lower bound on first-turn timestamp.' },
244
+ until: { type: 'string', description: 'ISO timestamp upper bound on first-turn timestamp.' },
245
+ },
246
+ required: ['id'],
247
+ },
248
+ handler: queryConversationsHandler,
249
+ });
250
+
251
+ registerTool({
252
+ name: 'get_conversation',
253
+ description:
254
+ 'Get the full turn list for one conversation on a connected bot. Proxies through to the bot.',
255
+ inputSchema: {
256
+ type: 'object',
257
+ properties: {
258
+ id: { type: 'string', description: 'Deployment id.' },
259
+ conversationId: { type: 'string', description: 'Conversation id on the bot.' },
260
+ },
261
+ required: ['id', 'conversationId'],
262
+ },
263
+ handler: getConversationHandler,
264
+ });
265
+
266
+ registerTool({
267
+ name: 'export_conversations',
268
+ description:
269
+ 'Bulk export full conversations on a connected bot, including every turn. Optional startDate / endDate ISO bounds filter on the conversation\'s first turn. Returns one entry per conversation with the full turn list nested under each — use sparingly on bots with many conversations.',
270
+ inputSchema: {
271
+ type: 'object',
272
+ properties: {
273
+ id: { type: 'string', description: 'Deployment id.' },
274
+ startDate: { type: 'string', description: 'ISO timestamp lower bound on first-turn timestamp.' },
275
+ endDate: { type: 'string', description: 'ISO timestamp upper bound on first-turn timestamp.' },
276
+ },
277
+ required: ['id'],
278
+ },
279
+ handler: exportConversationsHandler,
280
+ });
281
+
282
+ registerTool({
283
+ name: 'query_submissions',
284
+ description:
285
+ 'List form-gathering submissions on a connected bot. Proxies through to the bot.',
286
+ inputSchema: {
287
+ type: 'object',
288
+ properties: {
289
+ id: { type: 'string', description: 'Deployment id.' },
290
+ limit: { type: 'integer', minimum: 1, maximum: 200 },
291
+ offset: { type: 'integer', minimum: 0 },
292
+ since: { type: 'string' },
293
+ until: { type: 'string' },
294
+ },
295
+ required: ['id'],
296
+ },
297
+ handler: querySubmissionsHandler,
298
+ });
299
+
300
+ registerTool({
301
+ name: 'verify_chain',
302
+ description:
303
+ 'Walk the tamper-evident hash chain for one conversation. Returns the verification result from the bot. See docs/turn-hashing.md for the chain semantics.',
304
+ inputSchema: {
305
+ type: 'object',
306
+ properties: {
307
+ id: { type: 'string', description: 'Deployment id.' },
308
+ conversationId: { type: 'string', description: 'Conversation id on the bot.' },
309
+ },
310
+ required: ['id', 'conversationId'],
311
+ },
312
+ handler: verifyChainHandler,
313
+ });
314
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Map the wizard's in-progress formData into the shape the bot's client
3
+ * (lite-template/client/index.html) expects from a deployed container's
4
+ * `/context` endpoint. This is what the preview iframe reads via
5
+ * `window.__INITIAL_CONFIG__`.
6
+ *
7
+ * Production source of truth: mojulo-lite/lite-template/server.js — the
8
+ * `/` route (line ~186) and `/context` route (line ~963) build the same
9
+ * object from on-disk config files. We mirror that shape here, but pulled
10
+ * from React state instead.
11
+ */
12
+
13
+ import { buildLLMConfig } from '@/lib/config-builder';
14
+
15
+ function safeParseFormJson(json) {
16
+ if (!json) return null;
17
+ try {
18
+ return typeof json === 'string' ? JSON.parse(json) : json;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Build the `botContext` payload from wizard formData + enabledProtocols.
26
+ * Returns null if the bare-minimum LLM config isn't ready yet (no provider
27
+ * or no credential) so the preview can show a setup hint instead of booting
28
+ * a half-configured bot.
29
+ *
30
+ * Credential can come in three shapes:
31
+ * - formData.apiKey pasted plaintext (rides in the llm block)
32
+ * - formData.apiKeyId saved-key reference (decrypted server-side at
33
+ * /api/preview/chat — mirrors the deploy path)
34
+ * - formData.hasStoredApiKey + formData.editDeploymentId
35
+ * edit mode reusing the existing on-file key:
36
+ * the chat route looks it up from the deployment
37
+ * row server-side. The browser never sees the
38
+ * plaintext.
39
+ */
40
+ export function buildPreviewConfig(formData, enabledProtocols) {
41
+ // Ollama is credential-less — provider + model are sufficient to boot
42
+ // the preview. The host falls back to LLM_PROVIDERS.ollama.defaultHost in
43
+ // buildLLMConfig when ollamaHost is blank.
44
+ const isOllama = formData?.provider === 'ollama';
45
+ const hasCredential = isOllama || Boolean(
46
+ formData?.apiKey ||
47
+ formData?.apiKeyId ||
48
+ (formData?.hasStoredApiKey && formData?.editDeploymentId),
49
+ );
50
+ if (!formData?.provider || !hasCredential || !formData?.model) {
51
+ return null;
52
+ }
53
+
54
+ const llm = buildLLMConfig(formData.provider, formData.apiKey || '', formData.model, {
55
+ maxTokens: 2048,
56
+ ollamaHost: formData.ollamaHost,
57
+ });
58
+
59
+ const formStructure = enabledProtocols.formGathering
60
+ ? safeParseFormJson(formData.generatedFormJson)
61
+ : null;
62
+
63
+ const botContext = {
64
+ name: formData.botName || 'Preview Bot',
65
+ chatDisplayName:
66
+ formData.uiSettings?.chatDisplayName || formData.chatDisplayName || 'Bot',
67
+ placeholder:
68
+ formData.uiSettings?.placeholder ||
69
+ formData.placeholder ||
70
+ 'Type your message...',
71
+ firstMessage:
72
+ formData.firstMessage || 'Hello! How can I help you today?',
73
+ suggestedPrompts: formData.suggestedPrompts || [],
74
+
75
+ isForm: Boolean(enabledProtocols.formGathering && formStructure),
76
+ formStructure: formStructure || undefined,
77
+ formCompletionWebhook: formData.formCompletionWebhook || '',
78
+ afterSubmitChatMessage: formData.afterSubmitChatMessage || '',
79
+ formSendHome: Boolean(formData.formSendHome),
80
+ termsAndConditions: formData.termsAndConditions || '',
81
+
82
+ isCalendar: Boolean(
83
+ enabledProtocols.appointments &&
84
+ formData.appointmentDestinations?.length > 0,
85
+ ),
86
+ calendarConfig: enabledProtocols.appointments
87
+ ? formData.appointmentDestinations || []
88
+ : [],
89
+
90
+ isTriage: Boolean(
91
+ enabledProtocols.triage && formData.triageRoutes?.length > 0,
92
+ ),
93
+ triageRoutes: enabledProtocols.triage ? formData.triageRoutes || [] : [],
94
+
95
+ isOpticalRead: Boolean(
96
+ enabledProtocols.opticalRead && formData.opticalReadFields?.length > 0,
97
+ ),
98
+ opticalReadFields: enabledProtocols.opticalRead
99
+ ? formData.opticalReadFields || []
100
+ : [],
101
+ opticalReadShowUploadOnStart: Boolean(
102
+ enabledProtocols.opticalRead && formData.opticalReadShowUploadOnStart,
103
+ ),
104
+ opticalReadAfterSubmitMessage: enabledProtocols.opticalRead
105
+ ? formData.opticalReadAfterSubmitMessage || ''
106
+ : '',
107
+ };
108
+
109
+ // The shim uses these to translate /chat calls into /api/preview/chat
110
+ // calls. They aren't part of the deployed bot's /context shape — they
111
+ // ride along as `__previewMeta` so the production client never sees them.
112
+ const previewMeta = {
113
+ objective:
114
+ formData.objective || `Help users as ${formData.botName || 'a bot'}.`,
115
+ enabledProtocols,
116
+ protocolData: {
117
+ ...(formStructure ? { formStructure } : {}),
118
+ ...(enabledProtocols.appointments
119
+ ? { appointments: formData.appointmentDestinations || [] }
120
+ : {}),
121
+ ...(enabledProtocols.triage
122
+ ? { triage: formData.triageRoutes || [] }
123
+ : {}),
124
+ ...(enabledProtocols.opticalRead
125
+ ? { opticalRead: { fields: formData.opticalReadFields || [] } }
126
+ : {}),
127
+ },
128
+ llm,
129
+ apiKeyId: formData.apiKeyId || null,
130
+ editDeploymentId: formData.editDeploymentId || null,
131
+ documentIds: (formData.documents || []).map((d) => d.id),
132
+ embeddingsStorageKey: formData.embeddings?.storageKey || null,
133
+ };
134
+
135
+ return { botContext, previewMeta };
136
+ }
@@ -0,0 +1,11 @@
1
+ // Lite runs locally for a single operator — no need to rate-limit ourselves.
2
+ // Keep the shape the copied stream route expects.
3
+
4
+ export const RateLimitPresets = {
5
+ expensive: { windowMs: 60_000, max: 60 },
6
+ default: { windowMs: 60_000, max: 300 },
7
+ };
8
+
9
+ export function checkRateLimit(_request, _options) {
10
+ return { allowed: true };
11
+ }
@@ -0,0 +1,142 @@
1
+ import { ApiKeyRepository } from './db/repositories/apiKeys.js';
2
+ import { decryptApiKey } from './deployment-auth.js';
3
+ import { buildBedrockModelId } from './llm-providers.js';
4
+
5
+ /**
6
+ * Inject a saved (encrypted) provider credential into a deployment config so
7
+ * the browser never has to handle plaintext when picking a saved key. The
8
+ * wizard sends apiKeyId; the route hands the deployment config + id here, and
9
+ * we patch config.llm[provider] in place. Throws if the id is unknown or its
10
+ * provider doesn't match config.llm.provider — both indicate a stale UI state
11
+ * worth surfacing.
12
+ */
13
+ export async function resolveSavedApiKeyIntoConfig(config, apiKeyId) {
14
+ if (!apiKeyId || !config?.llm?.provider) return config;
15
+
16
+ const record = await ApiKeyRepository.findById(apiKeyId);
17
+ if (!record) {
18
+ throw new Error(`Saved API key ${apiKeyId} not found`);
19
+ }
20
+
21
+ const provider = config.llm.provider;
22
+ if (record.provider !== provider) {
23
+ throw new Error(
24
+ `Saved API key provider "${record.provider}" does not match selected provider "${provider}"`
25
+ );
26
+ }
27
+
28
+ const plaintext = decryptApiKey(record.encryptedKey);
29
+
30
+ if (provider === 'bedrock') {
31
+ const credentials = JSON.parse(plaintext);
32
+ const region = credentials.region || 'us-east-1';
33
+ const baseModel = config.llm.bedrock?.model || '';
34
+ config.llm.bedrock = {
35
+ ...config.llm.bedrock,
36
+ region,
37
+ useIamRole: credentials.useIamRole || false,
38
+ accessKeyId: credentials.accessKeyId || null,
39
+ secretAccessKey: credentials.secretAccessKey || null,
40
+ model: buildBedrockModelId(baseModel, region),
41
+ };
42
+ } else if (provider === 'ollama') {
43
+ // Ollama's "credential" row stores {"host": "..."} JSON. The host stamps
44
+ // onto config.llm.ollama just like buildLLMConfig would have done from
45
+ // the wizard's ollamaHost field — saved-host references and pasted
46
+ // hosts converge on the same artifact shape.
47
+ const parsed = JSON.parse(plaintext);
48
+ config.llm.ollama = {
49
+ ...config.llm.ollama,
50
+ host: parsed.host || config.llm.ollama?.host || '',
51
+ };
52
+ } else {
53
+ config.llm[provider] = {
54
+ ...config.llm[provider],
55
+ apiKey: plaintext,
56
+ };
57
+ }
58
+
59
+ return config;
60
+ }
61
+
62
+ /**
63
+ * Whether a deployment config currently carries a usable provider credential
64
+ * for its selected provider. Computed against the un-redacted config so the
65
+ * GET endpoint can advertise "key on file" without exposing the value. The
66
+ * wizard reads this to gate the credential requirement in edit mode.
67
+ */
68
+ export function configHasStoredApiKey(config) {
69
+ const provider = config?.llm?.provider;
70
+ if (!provider) return false;
71
+ const block = config.llm[provider];
72
+ if (!block) return false;
73
+ if (provider === 'bedrock') {
74
+ return !!(block.useIamRole || (block.accessKeyId && block.secretAccessKey));
75
+ }
76
+ if (provider === 'ollama') {
77
+ // Host is the only transport field. Empty host means the wizard will
78
+ // fall back to LLM_PROVIDERS.ollama.defaultHost at build time, which is
79
+ // still a usable artifact — but the "on file" claim should be honest
80
+ // about whether the user actually configured one.
81
+ return !!block.host;
82
+ }
83
+ return !!block.apiKey;
84
+ }
85
+
86
+ /**
87
+ * Strip provider credentials from a deployment config before returning it
88
+ * to the browser. The deployment row stores plaintext (it's what gets baked
89
+ * into the artifact's .env), but edit-mode hydration shouldn't have to
90
+ * surface it to populate the wizard. Operates on a clone — caller's object
91
+ * is not mutated.
92
+ */
93
+ export function redactApiKeysFromConfig(config) {
94
+ if (!config?.llm) return config;
95
+ const clone = structuredClone(config);
96
+ for (const key of Object.keys(clone.llm)) {
97
+ if (key === 'provider') continue;
98
+ const block = clone.llm[key];
99
+ if (!block || typeof block !== 'object') continue;
100
+ if ('apiKey' in block) block.apiKey = '';
101
+ if ('accessKeyId' in block) block.accessKeyId = null;
102
+ if ('secretAccessKey' in block) block.secretAccessKey = null;
103
+ }
104
+ return clone;
105
+ }
106
+
107
+ /**
108
+ * When PATCH'ing an existing deployment without a fresh credential — the
109
+ * wizard hydrated from the redacted GET and the user didn't paste/pick a
110
+ * new key — copy the previously-stored credentials forward so the artifact
111
+ * keeps working. Mutates newConfig in place. Provider-switch is detected by
112
+ * comparing llm.provider; on switch we don't carry credentials across.
113
+ */
114
+ export function preserveExistingCredentials(newConfig, oldConfig) {
115
+ const provider = newConfig?.llm?.provider;
116
+ if (!provider || !oldConfig?.llm) return newConfig;
117
+ if (oldConfig.llm.provider !== provider) return newConfig;
118
+
119
+ const newBlock = newConfig.llm[provider];
120
+ const oldBlock = oldConfig.llm[provider];
121
+ if (!newBlock || !oldBlock) return newConfig;
122
+
123
+ if (provider === 'bedrock') {
124
+ const newHasCreds = newBlock.useIamRole || (newBlock.accessKeyId && newBlock.secretAccessKey);
125
+ if (!newHasCreds) {
126
+ newBlock.useIamRole = oldBlock.useIamRole;
127
+ newBlock.accessKeyId = oldBlock.accessKeyId;
128
+ newBlock.secretAccessKey = oldBlock.secretAccessKey;
129
+ newBlock.region = newBlock.region || oldBlock.region;
130
+ }
131
+ } else if (provider === 'ollama') {
132
+ // Ollama carries host instead of a credential. Edit mode that doesn't
133
+ // re-submit a host should keep the previously stored one.
134
+ if (!newBlock.host) {
135
+ newBlock.host = oldBlock.host || '';
136
+ }
137
+ } else if (!newBlock.apiKey) {
138
+ newBlock.apiKey = oldBlock.apiKey || '';
139
+ }
140
+
141
+ return newConfig;
142
+ }
@@ -0,0 +1,40 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ const STORAGE_ROOT =
5
+ process.env.STORAGE_ROOT || path.join(process.cwd(), 'data', 'storage');
6
+
7
+ function resolveStoragePath(key) {
8
+ // Prevent traversal escape
9
+ const safe = key.replace(/^\/+/, '').replace(/\.\./g, '_');
10
+ return path.join(STORAGE_ROOT, safe);
11
+ }
12
+
13
+ async function ensureDir(filePath) {
14
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
15
+ }
16
+
17
+ export async function uploadFile(key, buffer, _legacyIgnored, _opts = {}) {
18
+ const dest = resolveStoragePath(key);
19
+ await ensureDir(dest);
20
+ await fs.writeFile(dest, buffer);
21
+ return { storagePath: key };
22
+ }
23
+
24
+ export async function downloadToBuffer(key) {
25
+ const source = resolveStoragePath(key);
26
+ return fs.readFile(source);
27
+ }
28
+
29
+ export async function deleteFile(key) {
30
+ const target = resolveStoragePath(key);
31
+ try {
32
+ await fs.unlink(target);
33
+ } catch (err) {
34
+ if (err.code !== 'ENOENT') throw err;
35
+ }
36
+ }
37
+
38
+ export function getStorageRoot() {
39
+ return STORAGE_ROOT;
40
+ }