@diegoaltoworks/chatter 0.47.0 → 0.48.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 (118) hide show
  1. package/README.md +3 -4
  2. package/dist/auth/apikeys.d.ts.map +1 -1
  3. package/dist/auth/secretStrength.d.ts +8 -0
  4. package/dist/auth/secretStrength.d.ts.map +1 -0
  5. package/dist/bin/create-apikey.js +22 -3
  6. package/dist/bin/wa-pair.js +59 -11
  7. package/dist/channels/gates.d.ts +9 -0
  8. package/dist/channels/gates.d.ts.map +1 -1
  9. package/dist/channels/index.d.ts +1 -0
  10. package/dist/channels/index.d.ts.map +1 -1
  11. package/dist/channels/index.js +252 -8
  12. package/dist/channels/index.mjs +250 -8
  13. package/dist/channels/pipeline.d.ts +106 -0
  14. package/dist/channels/pipeline.d.ts.map +1 -0
  15. package/dist/channels/senders.d.ts +2 -1
  16. package/dist/channels/senders.d.ts.map +1 -1
  17. package/dist/channels/whatsapp/channel.d.ts +4 -0
  18. package/dist/channels/whatsapp/channel.d.ts.map +1 -1
  19. package/dist/channels/whatsapp/crypto.d.ts +17 -3
  20. package/dist/channels/whatsapp/crypto.d.ts.map +1 -1
  21. package/dist/channels/whatsapp/inbound.d.ts +34 -4
  22. package/dist/channels/whatsapp/inbound.d.ts.map +1 -1
  23. package/dist/channels/whatsapp/index.d.ts +2 -1
  24. package/dist/channels/whatsapp/index.d.ts.map +1 -1
  25. package/dist/channels/whatsapp/index.js +395 -159
  26. package/dist/channels/whatsapp/index.mjs +394 -160
  27. package/dist/channels/whatsapp/router.d.ts +107 -0
  28. package/dist/channels/whatsapp/router.d.ts.map +1 -0
  29. package/dist/core/answer.d.ts +5 -2
  30. package/dist/core/answer.d.ts.map +1 -1
  31. package/dist/core/logger.d.ts +23 -0
  32. package/dist/core/logger.d.ts.map +1 -0
  33. package/dist/core/messages.d.ts +8 -0
  34. package/dist/core/messages.d.ts.map +1 -1
  35. package/dist/core/pipeline.d.ts +2 -0
  36. package/dist/core/pipeline.d.ts.map +1 -1
  37. package/dist/core/retrieval.d.ts +4 -0
  38. package/dist/core/retrieval.d.ts.map +1 -1
  39. package/dist/flows/engine.d.ts +3 -0
  40. package/dist/flows/engine.d.ts.map +1 -1
  41. package/dist/flows/index.d.ts +1 -0
  42. package/dist/flows/index.d.ts.map +1 -1
  43. package/dist/flows/index.js +91 -25
  44. package/dist/flows/index.mjs +91 -26
  45. package/dist/flows/intent.d.ts +2 -1
  46. package/dist/flows/intent.d.ts.map +1 -1
  47. package/dist/flows/loader.d.ts +5 -3
  48. package/dist/flows/loader.d.ts.map +1 -1
  49. package/dist/flows/types.d.ts +9 -0
  50. package/dist/flows/types.d.ts.map +1 -1
  51. package/dist/history/index.d.ts +17 -0
  52. package/dist/history/index.d.ts.map +1 -0
  53. package/dist/history/index.js +100 -0
  54. package/dist/history/index.mjs +73 -0
  55. package/dist/history/tursoStore.d.ts +26 -0
  56. package/dist/history/tursoStore.d.ts.map +1 -0
  57. package/dist/history/types.d.ts +18 -0
  58. package/dist/history/types.d.ts.map +1 -0
  59. package/dist/index.d.ts +5 -2
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1146 -32444
  62. package/dist/index.mjs +1082 -32410
  63. package/dist/mcp-server/cost-tracker.d.ts +11 -6
  64. package/dist/mcp-server/cost-tracker.d.ts.map +1 -1
  65. package/dist/mcp-server/logger.d.ts +5 -2
  66. package/dist/mcp-server/logger.d.ts.map +1 -1
  67. package/dist/mcp-server/sdk.d.ts +23 -0
  68. package/dist/mcp-server/sdk.d.ts.map +1 -0
  69. package/dist/mcp-server/tool-input.d.ts +12 -0
  70. package/dist/mcp-server/tool-input.d.ts.map +1 -0
  71. package/dist/mcp-server/types.d.ts +10 -3
  72. package/dist/mcp-server/types.d.ts.map +1 -1
  73. package/dist/mcp-server.d.ts +1 -2
  74. package/dist/mcp-server.d.ts.map +1 -1
  75. package/dist/mcp-server.js +192 -111
  76. package/dist/mcp-server.mjs +179 -98
  77. package/dist/middleware/auth.d.ts.map +1 -1
  78. package/dist/middleware/bodyLimit.d.ts +11 -0
  79. package/dist/middleware/bodyLimit.d.ts.map +1 -0
  80. package/dist/middleware/clientKey.d.ts +15 -0
  81. package/dist/middleware/clientKey.d.ts.map +1 -0
  82. package/dist/middleware/cors.d.ts.map +1 -1
  83. package/dist/middleware/jwt.d.ts +0 -3
  84. package/dist/middleware/jwt.d.ts.map +1 -1
  85. package/dist/middleware/originMatch.d.ts +27 -0
  86. package/dist/middleware/originMatch.d.ts.map +1 -0
  87. package/dist/middleware/ratelimit.d.ts +0 -6
  88. package/dist/middleware/ratelimit.d.ts.map +1 -1
  89. package/dist/middleware/referrer.d.ts +5 -1
  90. package/dist/middleware/referrer.d.ts.map +1 -1
  91. package/dist/middleware/securityHeaders.d.ts +20 -0
  92. package/dist/middleware/securityHeaders.d.ts.map +1 -0
  93. package/dist/middleware/session.d.ts +2 -1
  94. package/dist/middleware/session.d.ts.map +1 -1
  95. package/dist/middleware/windowBucket.d.ts +19 -0
  96. package/dist/middleware/windowBucket.d.ts.map +1 -0
  97. package/dist/personas/index.js +20 -1
  98. package/dist/personas/index.mjs +20 -1
  99. package/dist/personas/resolver.d.ts +3 -0
  100. package/dist/personas/resolver.d.ts.map +1 -1
  101. package/dist/routes/demo.d.ts.map +1 -1
  102. package/dist/routes/openai.d.ts.map +1 -1
  103. package/dist/routes/private.d.ts.map +1 -1
  104. package/dist/routes/public.d.ts.map +1 -1
  105. package/dist/scheduler/compose.d.ts +2 -1
  106. package/dist/scheduler/compose.d.ts.map +1 -1
  107. package/dist/scheduler/index.js +29 -9
  108. package/dist/scheduler/index.mjs +29 -9
  109. package/dist/scheduler/scheduler.d.ts +3 -0
  110. package/dist/scheduler/scheduler.d.ts.map +1 -1
  111. package/dist/scheduler/tick.d.ts +2 -0
  112. package/dist/scheduler/tick.d.ts.map +1 -1
  113. package/dist/server.d.ts.map +1 -1
  114. package/dist/server.js +678 -528
  115. package/dist/server.mjs +678 -528
  116. package/dist/types.d.ts +87 -0
  117. package/dist/types.d.ts.map +1 -1
  118. package/package.json +22 -7
package/dist/server.mjs CHANGED
@@ -7,12 +7,25 @@ import OpenAI from "openai";
7
7
  // src/auth/apikeys.ts
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { jwtVerify, SignJWT } from "jose";
10
+
11
+ // src/auth/secretStrength.ts
12
+ var MIN_SECRET_LENGTH = 16;
13
+ function assertStrongSecret(secret, label) {
14
+ if (!secret) {
15
+ throw new Error(`${label} is required and cannot be empty.`);
16
+ }
17
+ if (secret.length < MIN_SECRET_LENGTH) {
18
+ throw new Error(
19
+ `${label} is too weak: must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). Generate one with \`openssl rand -base64 32\`.`
20
+ );
21
+ }
22
+ }
23
+
24
+ // src/auth/apikeys.ts
10
25
  var ApiKeyManager = class {
11
26
  secretBytes;
12
27
  constructor(secret) {
13
- if (!secret) {
14
- throw new Error("API key secret is required");
15
- }
28
+ assertStrongSecret(secret, "API key secret (CHATTER_SECRET / config.auth.secret)");
16
29
  this.secretBytes = new TextEncoder().encode(secret);
17
30
  }
18
31
  /**
@@ -115,13 +128,256 @@ var ApiKeyManager = class {
115
128
  }
116
129
  };
117
130
 
131
+ // src/core/guardrails.ts
132
+ function detectLeakage(text) {
133
+ const flags = [
134
+ /system prompt/i,
135
+ /hidden instruction/i,
136
+ /here (are|is) (my|the) rules/i,
137
+ /BEGIN SYSTEM PROMPT/i,
138
+ /tool instructions/i
139
+ ];
140
+ return flags.some((rx) => rx.test(text));
141
+ }
142
+ function scrubOutput(text) {
143
+ return text.replace(/sk-[A-Za-z0-9]{15,}/g, "[REDACTED]").replace(/AIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]");
144
+ }
145
+ function guardOutput(text) {
146
+ if (detectLeakage(text)) {
147
+ return "Sorry, I can't share internal instructions. How else can I help?";
148
+ }
149
+ return scrubOutput(text);
150
+ }
151
+
152
+ // src/core/llm.ts
153
+ var DEFAULT_MODEL = "gpt-4o";
154
+ async function completeOnce({
155
+ client,
156
+ system,
157
+ messages,
158
+ temperature = 0.2,
159
+ model = DEFAULT_MODEL
160
+ }) {
161
+ const res = await client.chat.completions.create({
162
+ model,
163
+ temperature,
164
+ messages: [{ role: "system", content: system }, ...messages]
165
+ });
166
+ const text = res.choices[0]?.message?.content ?? "";
167
+ return {
168
+ content: guardOutput(text),
169
+ usage: res.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
170
+ };
171
+ }
172
+ async function* completeStream({
173
+ client,
174
+ system,
175
+ messages,
176
+ temperature = 0.2,
177
+ model = DEFAULT_MODEL
178
+ }) {
179
+ const stream5 = await client.chat.completions.create({
180
+ model,
181
+ temperature,
182
+ stream: true,
183
+ messages: [{ role: "system", content: system }, ...messages]
184
+ });
185
+ for await (const chunk2 of stream5) {
186
+ const delta = chunk2.choices[0]?.delta?.content ?? "";
187
+ if (delta) yield scrubOutput(delta);
188
+ }
189
+ }
190
+
191
+ // src/core/answer.ts
192
+ var noUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
193
+ async function runAnswerFn(answerFn, { system, messages, mode, sender, conversationId }) {
194
+ const result = await answerFn({ system, messages, mode, sender, conversationId });
195
+ const raw = typeof result === "string" ? result : result?.content;
196
+ const usage = typeof result === "string" ? void 0 : result?.usage;
197
+ return {
198
+ content: guardOutput(typeof raw === "string" ? raw : ""),
199
+ usage: usage ?? noUsage()
200
+ };
201
+ }
202
+ async function answerOnce({
203
+ answerFn,
204
+ client,
205
+ system,
206
+ messages,
207
+ mode,
208
+ sender,
209
+ conversationId,
210
+ temperature,
211
+ model
212
+ }) {
213
+ if (answerFn) {
214
+ return runAnswerFn(answerFn, { system, messages, mode, sender, conversationId });
215
+ }
216
+ return completeOnce({ client, system, messages, temperature, model });
217
+ }
218
+ async function* answerStream({
219
+ answerFn,
220
+ client,
221
+ system,
222
+ messages,
223
+ mode,
224
+ sender,
225
+ conversationId,
226
+ temperature,
227
+ model
228
+ }) {
229
+ if (answerFn) {
230
+ const { content } = await runAnswerFn(answerFn, {
231
+ system,
232
+ messages,
233
+ mode,
234
+ sender,
235
+ conversationId
236
+ });
237
+ if (content) yield content;
238
+ return;
239
+ }
240
+ yield* completeStream({ client, system, messages, temperature, model });
241
+ }
242
+
243
+ // src/core/buckets.ts
244
+ function defaultBuckets(mode) {
245
+ return ["base", mode];
246
+ }
247
+ function isIdentified(sender) {
248
+ return typeof sender === "string" && sender.trim().length > 0;
249
+ }
250
+ function normalize(buckets) {
251
+ const out = [];
252
+ for (const raw of buckets) {
253
+ if (typeof raw !== "string") continue;
254
+ const bucket = raw.trim();
255
+ if (bucket && !out.includes(bucket)) out.push(bucket);
256
+ }
257
+ return out;
258
+ }
259
+ async function resolveBuckets({
260
+ mode,
261
+ sender,
262
+ bucketsFor
263
+ }) {
264
+ if (!bucketsFor) return void 0;
265
+ const identified = isIdentified(sender);
266
+ const requested = await bucketsFor(identified ? { mode, sender } : { mode });
267
+ if (!Array.isArray(requested)) return void 0;
268
+ const cleaned = normalize(requested);
269
+ if (identified) return cleaned;
270
+ const ceiling = new Set(defaultBuckets(mode));
271
+ return cleaned.filter((bucket) => ceiling.has(bucket));
272
+ }
273
+
274
+ // src/core/messages.ts
275
+ function isClientRole(role) {
276
+ return role === "user" || role === "assistant";
277
+ }
278
+ function lastUserMessage(messages) {
279
+ return [...messages].reverse().find((m) => m.role === "user");
280
+ }
281
+ function normalizeMessages(raw) {
282
+ if (!Array.isArray(raw) || raw.length === 0) return null;
283
+ const messages = [];
284
+ for (const entry of raw) {
285
+ if (!entry || typeof entry !== "object") return null;
286
+ const { role, content } = entry;
287
+ if (!isClientRole(role)) continue;
288
+ let text = null;
289
+ if (typeof content === "string") {
290
+ text = content;
291
+ } else if (Array.isArray(content)) {
292
+ text = content.filter(
293
+ (part) => !!part && typeof part === "object" && part.type === "text" && typeof part.text === "string"
294
+ ).map((part) => part.text).join("");
295
+ }
296
+ if (text === null) continue;
297
+ messages.push({ role, content: text });
298
+ }
299
+ return messages.length > 0 ? messages : null;
300
+ }
301
+ function normalizeChatBody(body) {
302
+ const source = body && typeof body === "object" ? body : {};
303
+ if (Array.isArray(source.messages)) {
304
+ if (source.messages.length === 0) {
305
+ return { ok: false, error: "messages array cannot be empty" };
306
+ }
307
+ const messages = normalizeMessages(source.messages);
308
+ if (!messages) {
309
+ return {
310
+ ok: false,
311
+ error: "messages must contain user/assistant messages with text content"
312
+ };
313
+ }
314
+ return { ok: true, messages };
315
+ }
316
+ const { message } = source;
317
+ if (message && typeof message !== "object") {
318
+ return { ok: true, messages: [{ role: "user", content: String(message) }] };
319
+ }
320
+ return { ok: false, error: "either 'message' or 'messages' required" };
321
+ }
322
+
323
+ // src/core/pipeline.ts
324
+ var MODE_SETTINGS = {
325
+ public: { topK: 6, label: "Context" },
326
+ private: { topK: 8, label: "Internal Context" }
327
+ };
328
+ async function prepareChat({
329
+ store,
330
+ prompts,
331
+ mode,
332
+ messages,
333
+ personaLayer,
334
+ channelHint,
335
+ buckets
336
+ }) {
337
+ const lastUserMsg = lastUserMessage(messages);
338
+ if (!lastUserMsg) {
339
+ throw new Error("no user message found in conversation");
340
+ }
341
+ const { topK, label } = MODE_SETTINGS[mode];
342
+ const ctx = await store.query(lastUserMsg.content, topK, buckets ?? defaultBuckets(mode));
343
+ const persona = personaLayer?.trim() || (mode === "private" ? prompts.privatePersona : prompts.publicPersona);
344
+ const hint = channelHint?.trim();
345
+ const system = [
346
+ prompts.baseSystemRules,
347
+ persona,
348
+ ...hint ? [hint] : [],
349
+ `${label}:
350
+ ${ctx.join("\n\n")}`
351
+ ].join("\n\n");
352
+ return { system, messages, context: ctx };
353
+ }
354
+
355
+ // src/core/logger.ts
356
+ var LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
357
+ function createConsoleLogger(level = "info") {
358
+ const threshold = LEVEL_ORDER[level];
359
+ const write = (at) => (...args) => {
360
+ if (LEVEL_ORDER[at] < threshold) return;
361
+ console.error(`[${at}]`, ...args);
362
+ };
363
+ return {
364
+ debug: write("debug"),
365
+ info: write("info"),
366
+ warn: write("warn"),
367
+ error: write("error")
368
+ };
369
+ }
370
+ function resolveLogger(logger, level) {
371
+ return logger ?? createConsoleLogger(level);
372
+ }
373
+
118
374
  // src/channels/senders.ts
119
- function createSenderRegistry() {
375
+ function createSenderRegistry(logger = createConsoleLogger()) {
120
376
  const senders = /* @__PURE__ */ new Map();
121
377
  return {
122
378
  register(name, sender) {
123
379
  if (senders.has(name)) {
124
- console.warn(`Channel sender "${name}" is already registered; overwriting.`);
380
+ logger.warn(`Channel sender "${name}" is already registered; overwriting.`);
125
381
  }
126
382
  senders.set(name, sender);
127
383
  },
@@ -138,7 +394,7 @@ function createSenderRegistry() {
138
394
  await sender.sendText(chatId, text);
139
395
  return true;
140
396
  } catch (error) {
141
- console.warn(`Channel sender "${name}" sendText failed:`, error);
397
+ logger.warn(`Channel sender "${name}" sendText failed:`, error);
142
398
  return false;
143
399
  }
144
400
  },
@@ -149,7 +405,7 @@ function createSenderRegistry() {
149
405
  await sender.sendVoice(chatId, payload);
150
406
  return true;
151
407
  } catch (error) {
152
- console.warn(`Channel sender "${name}" sendVoice failed:`, error);
408
+ logger.warn(`Channel sender "${name}" sendVoice failed:`, error);
153
409
  return false;
154
410
  }
155
411
  },
@@ -160,7 +416,7 @@ function createSenderRegistry() {
160
416
  await sender.sendMedia(chatId, payload);
161
417
  return true;
162
418
  } catch (error) {
163
- console.warn(`Channel sender "${name}" sendMedia failed:`, error);
419
+ logger.warn(`Channel sender "${name}" sendMedia failed:`, error);
164
420
  return false;
165
421
  }
166
422
  }
@@ -259,6 +515,7 @@ var VectorStore = class _VectorStore {
259
515
  authToken: options.databaseAuthToken
260
516
  });
261
517
  this.knowledgeDir = options.knowledgeDir || "./config/knowledge";
518
+ this.logger = options.logger ?? createConsoleLogger();
262
519
  }
263
520
  client;
264
521
  /**
@@ -268,9 +525,10 @@ var VectorStore = class _VectorStore {
268
525
  */
269
526
  db;
270
527
  knowledgeDir;
528
+ logger;
271
529
  // On boot: ingest new chunks and embed only missing ones.
272
530
  async build() {
273
- console.log("\u{1F504} Building knowledge base...");
531
+ this.logger.info("\u{1F504} Building knowledge base...");
274
532
  await this.db.execute(`
275
533
  CREATE TABLE IF NOT EXISTS chunks (id TEXT PRIMARY KEY, bucket TEXT NOT NULL, source TEXT NOT NULL, text TEXT NOT NULL);
276
534
  `);
@@ -279,7 +537,7 @@ var VectorStore = class _VectorStore {
279
537
  `);
280
538
  await this.db.execute("CREATE INDEX IF NOT EXISTS idx_chunks_bucket ON chunks(bucket);");
281
539
  const docs = loadKnowledge(this.knowledgeDir);
282
- console.log(`\u{1F4DA} Loaded ${docs.length} knowledge documents`);
540
+ this.logger.info(`\u{1F4DA} Loaded ${docs.length} knowledge documents`);
283
541
  const rows = [];
284
542
  for (const d of docs) {
285
543
  for (const part of chunk(d.text)) {
@@ -287,21 +545,19 @@ var VectorStore = class _VectorStore {
287
545
  rows.push({ id, bucket: d.bucket, source: d.source, text: part });
288
546
  }
289
547
  }
290
- console.log(`\u{1F4E6} Created ${rows.length} chunks from knowledge documents`);
548
+ this.logger.info(`\u{1F4E6} Created ${rows.length} chunks from knowledge documents`);
291
549
  await this.cleanupStaleChunks(rows.map((r) => r.id));
292
- const tx = await this.db.transaction("write");
293
- try {
294
- for (const r of rows) {
295
- await tx.execute({
550
+ const UPSERT_BATCH = 500;
551
+ for (let i = 0; i < rows.length; i += UPSERT_BATCH) {
552
+ const batch = rows.slice(i, i + UPSERT_BATCH);
553
+ await this.db.batch(
554
+ batch.map((r) => ({
296
555
  sql: `INSERT INTO chunks(id,bucket,source,text) VALUES(?,?,?,?)
297
556
  ON CONFLICT(id) DO NOTHING`,
298
557
  args: [r.id, r.bucket, r.source, r.text]
299
- });
300
- }
301
- await tx.commit();
302
- } catch (e) {
303
- await tx.rollback();
304
- throw e;
558
+ })),
559
+ "write"
560
+ );
305
561
  }
306
562
  const ids = rows.map((r) => r.id);
307
563
  const missing = [];
@@ -316,32 +572,25 @@ var VectorStore = class _VectorStore {
316
572
  for (const row of res.rows) missing.push(String(row.id));
317
573
  }
318
574
  if (missing.length === 0) {
319
- console.log("\u2705 No new chunks to embed - knowledge base is up to date");
575
+ this.logger.info("\u2705 No new chunks to embed - knowledge base is up to date");
320
576
  return;
321
577
  }
322
- console.log(`\u{1F52E} Embedding ${missing.length} new/updated chunks with ${EMB_MODEL}...`);
578
+ this.logger.info(`\u{1F52E} Embedding ${missing.length} new/updated chunks with ${EMB_MODEL}...`);
323
579
  const textById = new Map(rows.map((r) => [r.id, r.text]));
324
580
  const BATCH = 96;
325
581
  for (let i = 0; i < missing.length; i += BATCH) {
326
582
  const batchIds = missing.slice(i, i + BATCH);
327
583
  const inputs = batchIds.map((id) => textById.get(id) || "");
328
584
  const emb = await this.client.embeddings.create({ model: EMB_MODEL, input: inputs });
329
- const tx2 = await this.db.transaction("write");
330
- try {
331
- emb.data.forEach((d, idx) => {
332
- const id = batchIds[idx];
333
- tx2.execute({
334
- sql: "INSERT INTO embeddings(id,model,embedding) VALUES(?,?,?)",
335
- args: [id, EMB_MODEL, JSON.stringify(d.embedding)]
336
- });
337
- });
338
- await tx2.commit();
339
- } catch (e) {
340
- await tx2.rollback();
341
- throw e;
342
- }
585
+ await this.db.batch(
586
+ emb.data.map((d, idx) => ({
587
+ sql: "INSERT INTO embeddings(id,model,embedding) VALUES(?,?,?)",
588
+ args: [batchIds[idx], EMB_MODEL, JSON.stringify(d.embedding)]
589
+ })),
590
+ "write"
591
+ );
343
592
  }
344
- console.log(`\u2705 Successfully embedded ${missing.length} new chunks`);
593
+ this.logger.info(`\u2705 Successfully embedded ${missing.length} new chunks`);
345
594
  }
346
595
  // Remove chunks from database that no longer exist in markdown files
347
596
  async cleanupStaleChunks(currentIds) {
@@ -352,7 +601,7 @@ var VectorStore = class _VectorStore {
352
601
  if (staleIds.length === 0) {
353
602
  return;
354
603
  }
355
- console.log(`\u{1F9F9} Cleaning up ${staleIds.length} stale chunks...`);
604
+ this.logger.info(`\u{1F9F9} Cleaning up ${staleIds.length} stale chunks...`);
356
605
  const BATCH_SIZE = 500;
357
606
  for (let i = 0; i < staleIds.length; i += BATCH_SIZE) {
358
607
  const batch = staleIds.slice(i, i + BATCH_SIZE);
@@ -362,7 +611,7 @@ var VectorStore = class _VectorStore {
362
611
  args: batch
363
612
  });
364
613
  }
365
- console.log(`\u2713 Cleaned up ${staleIds.length} stale chunks and their embeddings`);
614
+ this.logger.info(`\u2713 Cleaned up ${staleIds.length} stale chunks and their embeddings`);
366
615
  }
367
616
  static cosine(a, b) {
368
617
  let dot = 0;
@@ -411,278 +660,122 @@ var VectorStore = class _VectorStore {
411
660
  // src/core/serve-static.ts
412
661
  var STATIC_ADAPTERS = {
413
662
  bun: "hono/bun",
414
- node: "@hono/node-server/serve-static"
415
- };
416
- function detectRuntime() {
417
- return typeof globalThis.Bun === "undefined" ? "node" : "bun";
418
- }
419
- function wrapMissingAdapterError(runtime, cause) {
420
- const specifier = STATIC_ADAPTERS[runtime];
421
- return new Error(
422
- `Serving static files under ${runtime} requires '${specifier}', which could not be loaded. ` + (runtime === "node" ? "Install the optional peer dependency with `npm install @hono/node-server` (or bun/pnpm/yarn), or run Chatter headless (`features.headless: true`) if it should serve no static assets." : "Upgrade 'hono' to a version that ships the Bun adapter."),
423
- { cause }
424
- );
425
- }
426
- var cache = /* @__PURE__ */ new Map();
427
- function loadServeStatic(runtime = detectRuntime()) {
428
- const cached = cache.get(runtime);
429
- if (cached) return cached;
430
- const loading = (runtime === "bun" ? import("hono/bun") : import("@hono/node-server/serve-static")).then(
431
- (module) => module.serveStatic,
432
- (error) => {
433
- cache.delete(runtime);
434
- throw wrapMissingAdapterError(runtime, error);
435
- }
436
- );
437
- cache.set(runtime, loading);
438
- return loading;
439
- }
440
-
441
- // src/core/widgets.ts
442
- import { existsSync } from "node:fs";
443
- import { dirname, join as join3 } from "node:path";
444
- import { fileURLToPath } from "node:url";
445
- function resolveStatic(configStaticDir) {
446
- if (configStaticDir && existsSync(configStaticDir)) {
447
- return { staticDir: configStaticDir };
448
- }
449
- try {
450
- if (import.meta.url) {
451
- const moduleDir = dirname(fileURLToPath(import.meta.url));
452
- const staticDir = join3(moduleDir, "static");
453
- if (existsSync(staticDir)) return { staticDir };
454
- }
455
- } catch {
456
- }
457
- try {
458
- if (typeof __dirname !== "undefined") {
459
- const staticDir = join3(__dirname, "static");
460
- if (existsSync(staticDir)) return { staticDir };
461
- }
462
- } catch {
463
- }
464
- return { staticDir: null };
465
- }
466
-
467
- // src/middleware/cors.ts
468
- var ALLOWED_HEADERS = "content-type, x-api-key, authorization";
469
- var ALLOWED_METHODS = "POST, GET, OPTIONS";
470
- var cors = (allowedOrigins) => async (c, next) => {
471
- const requestOrigin = c.req.header("origin");
472
- const allowAll = !allowedOrigins || allowedOrigins.length === 0 || allowedOrigins.includes("*");
473
- const originHeader = resolveOrigin(requestOrigin, allowedOrigins, allowAll);
474
- if (c.req.method === "OPTIONS") {
475
- return new Response(null, {
476
- status: 204,
477
- headers: {
478
- "access-control-allow-origin": originHeader,
479
- "access-control-allow-headers": ALLOWED_HEADERS,
480
- "access-control-allow-methods": ALLOWED_METHODS,
481
- ...allowAll ? {} : { vary: "Origin" }
482
- }
483
- });
484
- }
485
- c.header("access-control-allow-origin", originHeader);
486
- c.header("access-control-allow-headers", ALLOWED_HEADERS);
487
- c.header("access-control-allow-methods", ALLOWED_METHODS);
488
- if (!allowAll) {
489
- c.header("vary", "Origin");
490
- }
491
- await next();
492
- };
493
- function resolveOrigin(requestOrigin, allowedOrigins, allowAll) {
494
- if (allowAll) return "*";
495
- if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
496
- return requestOrigin;
497
- }
498
- return allowedOrigins[0];
499
- }
500
-
501
- // src/routes/demo.ts
502
- import { Hono } from "hono";
503
- import { stream } from "hono/streaming";
504
-
505
- // src/core/guardrails.ts
506
- function detectLeakage(text) {
507
- const flags = [
508
- /system prompt/i,
509
- /hidden instruction/i,
510
- /here (are|is) (my|the) rules/i,
511
- /BEGIN SYSTEM PROMPT/i,
512
- /tool instructions/i
513
- ];
514
- return flags.some((rx) => rx.test(text));
515
- }
516
- function scrubOutput(text) {
517
- return text.replace(/sk-[A-Za-z0-9]{15,}/g, "[REDACTED]").replace(/AIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]");
518
- }
519
- function guardOutput(text) {
520
- if (detectLeakage(text)) {
521
- return "Sorry, I can't share internal instructions. How else can I help?";
522
- }
523
- return scrubOutput(text);
524
- }
525
-
526
- // src/core/llm.ts
527
- var DEFAULT_MODEL = "gpt-4o";
528
- async function completeOnce({
529
- client,
530
- system,
531
- messages,
532
- temperature = 0.2,
533
- model = DEFAULT_MODEL
534
- }) {
535
- const res = await client.chat.completions.create({
536
- model,
537
- temperature,
538
- messages: [{ role: "system", content: system }, ...messages]
539
- });
540
- const text = res.choices[0]?.message?.content ?? "";
541
- return {
542
- content: guardOutput(text),
543
- usage: res.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
544
- };
545
- }
546
- async function* completeStream({
547
- client,
548
- system,
549
- messages,
550
- temperature = 0.2,
551
- model = DEFAULT_MODEL
552
- }) {
553
- const stream5 = await client.chat.completions.create({
554
- model,
555
- temperature,
556
- stream: true,
557
- messages: [{ role: "system", content: system }, ...messages]
558
- });
559
- for await (const chunk2 of stream5) {
560
- const delta = chunk2.choices[0]?.delta?.content ?? "";
561
- if (delta) yield scrubOutput(delta);
562
- }
563
- }
564
-
565
- // src/core/answer.ts
566
- var noUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
567
- async function runAnswerFn(answerFn, { system, messages, mode, sender }) {
568
- const result = await answerFn({ system, messages, mode, sender });
569
- const raw = typeof result === "string" ? result : result?.content;
570
- const usage = typeof result === "string" ? void 0 : result?.usage;
571
- return {
572
- content: guardOutput(typeof raw === "string" ? raw : ""),
573
- usage: usage ?? noUsage()
574
- };
575
- }
576
- async function answerOnce({
577
- answerFn,
578
- client,
579
- system,
580
- messages,
581
- mode,
582
- sender,
583
- temperature,
584
- model
585
- }) {
586
- if (answerFn) {
587
- return runAnswerFn(answerFn, { system, messages, mode, sender });
588
- }
589
- return completeOnce({ client, system, messages, temperature, model });
590
- }
591
- async function* answerStream({
592
- answerFn,
593
- client,
594
- system,
595
- messages,
596
- mode,
597
- sender,
598
- temperature,
599
- model
600
- }) {
601
- if (answerFn) {
602
- const { content } = await runAnswerFn(answerFn, { system, messages, mode, sender });
603
- if (content) yield content;
604
- return;
605
- }
606
- yield* completeStream({ client, system, messages, temperature, model });
607
- }
608
-
609
- // src/core/buckets.ts
610
- function defaultBuckets(mode) {
611
- return ["base", mode];
612
- }
613
- function isIdentified(sender) {
614
- return typeof sender === "string" && sender.trim().length > 0;
615
- }
616
- function normalize(buckets) {
617
- const out = [];
618
- for (const raw of buckets) {
619
- if (typeof raw !== "string") continue;
620
- const bucket = raw.trim();
621
- if (bucket && !out.includes(bucket)) out.push(bucket);
622
- }
623
- return out;
624
- }
625
- async function resolveBuckets({
626
- mode,
627
- sender,
628
- bucketsFor
629
- }) {
630
- if (!bucketsFor) return void 0;
631
- const identified = isIdentified(sender);
632
- const requested = await bucketsFor(identified ? { mode, sender } : { mode });
633
- if (!Array.isArray(requested)) return void 0;
634
- const cleaned = normalize(requested);
635
- if (identified) return cleaned;
636
- const ceiling = new Set(defaultBuckets(mode));
637
- return cleaned.filter((bucket) => ceiling.has(bucket));
663
+ node: "@hono/node-server/serve-static"
664
+ };
665
+ function detectRuntime() {
666
+ return typeof globalThis.Bun === "undefined" ? "node" : "bun";
638
667
  }
639
-
640
- // src/core/messages.ts
641
- function isClientRole(role) {
642
- return role === "user" || role === "assistant";
668
+ function wrapMissingAdapterError(runtime, cause) {
669
+ const specifier = STATIC_ADAPTERS[runtime];
670
+ return new Error(
671
+ `Serving static files under ${runtime} requires '${specifier}', which could not be loaded. ` + (runtime === "node" ? "Install the optional peer dependency with `npm install @hono/node-server` (or bun/pnpm/yarn), or run Chatter headless (`features.headless: true`) if it should serve no static assets." : "Upgrade 'hono' to a version that ships the Bun adapter."),
672
+ { cause }
673
+ );
643
674
  }
644
- function normalizeMessages(raw) {
645
- if (!Array.isArray(raw) || raw.length === 0) return null;
646
- const messages = [];
647
- for (const entry of raw) {
648
- if (!entry || typeof entry !== "object") return null;
649
- const { role, content } = entry;
650
- if (!isClientRole(role)) continue;
651
- let text = null;
652
- if (typeof content === "string") {
653
- text = content;
654
- } else if (Array.isArray(content)) {
655
- text = content.filter(
656
- (part) => !!part && typeof part === "object" && part.type === "text" && typeof part.text === "string"
657
- ).map((part) => part.text).join("");
675
+ var cache = /* @__PURE__ */ new Map();
676
+ function loadServeStatic(runtime = detectRuntime()) {
677
+ const cached = cache.get(runtime);
678
+ if (cached) return cached;
679
+ const loading = (runtime === "bun" ? import("hono/bun") : import("@hono/node-server/serve-static")).then(
680
+ (module) => module.serveStatic,
681
+ (error) => {
682
+ cache.delete(runtime);
683
+ throw wrapMissingAdapterError(runtime, error);
658
684
  }
659
- if (text === null) continue;
660
- messages.push({ role, content: text });
661
- }
662
- return messages.length > 0 ? messages : null;
685
+ );
686
+ cache.set(runtime, loading);
687
+ return loading;
663
688
  }
664
- function normalizeChatBody(body) {
665
- const source = body && typeof body === "object" ? body : {};
666
- if (Array.isArray(source.messages)) {
667
- if (source.messages.length === 0) {
668
- return { ok: false, error: "messages array cannot be empty" };
689
+
690
+ // src/core/widgets.ts
691
+ import { existsSync } from "node:fs";
692
+ import { dirname, join as join3 } from "node:path";
693
+ import { fileURLToPath } from "node:url";
694
+ function resolveStatic(configStaticDir) {
695
+ if (configStaticDir && existsSync(configStaticDir)) {
696
+ return { staticDir: configStaticDir };
697
+ }
698
+ try {
699
+ if (import.meta.url) {
700
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
701
+ const staticDir = join3(moduleDir, "static");
702
+ if (existsSync(staticDir)) return { staticDir };
669
703
  }
670
- const messages = normalizeMessages(source.messages);
671
- if (!messages) {
672
- return {
673
- ok: false,
674
- error: "messages must contain user/assistant messages with text content"
675
- };
704
+ } catch {
705
+ }
706
+ try {
707
+ if (typeof __dirname !== "undefined") {
708
+ const staticDir = join3(__dirname, "static");
709
+ if (existsSync(staticDir)) return { staticDir };
676
710
  }
677
- return { ok: true, messages };
711
+ } catch {
678
712
  }
679
- const { message } = source;
680
- if (message && typeof message !== "object") {
681
- return { ok: true, messages: [{ role: "user", content: String(message) }] };
713
+ return { staticDir: null };
714
+ }
715
+
716
+ // src/middleware/cors.ts
717
+ var ALLOWED_HEADERS = "content-type, x-api-key, authorization, x-conversation-id";
718
+ var ALLOWED_METHODS = "POST, GET, OPTIONS";
719
+ var EXPOSED_HEADERS = "x-conversation-id";
720
+ var cors = (allowedOrigins) => async (c, next) => {
721
+ const requestOrigin = c.req.header("origin");
722
+ const allowAll = !allowedOrigins || allowedOrigins.length === 0 || allowedOrigins.includes("*");
723
+ const originHeader = resolveOrigin(requestOrigin, allowedOrigins, allowAll);
724
+ if (c.req.method === "OPTIONS") {
725
+ return new Response(null, {
726
+ status: 204,
727
+ headers: {
728
+ "access-control-allow-origin": originHeader,
729
+ "access-control-allow-headers": ALLOWED_HEADERS,
730
+ "access-control-allow-methods": ALLOWED_METHODS,
731
+ "access-control-expose-headers": EXPOSED_HEADERS,
732
+ ...allowAll ? {} : { vary: "Origin" }
733
+ }
734
+ });
682
735
  }
683
- return { ok: false, error: "either 'message' or 'messages' required" };
736
+ c.header("access-control-allow-origin", originHeader);
737
+ c.header("access-control-allow-headers", ALLOWED_HEADERS);
738
+ c.header("access-control-allow-methods", ALLOWED_METHODS);
739
+ c.header("access-control-expose-headers", EXPOSED_HEADERS);
740
+ if (!allowAll) {
741
+ c.header("vary", "Origin");
742
+ }
743
+ await next();
744
+ };
745
+ function resolveOrigin(requestOrigin, allowedOrigins, allowAll) {
746
+ if (allowAll) return "*";
747
+ if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
748
+ return requestOrigin;
749
+ }
750
+ return allowedOrigins[0];
751
+ }
752
+
753
+ // src/middleware/securityHeaders.ts
754
+ var DEFAULT_CSP = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'self'";
755
+ var DEFAULT_HSTS = "max-age=15552000; includeSubDomains";
756
+ function isHttps(c, trustProxy) {
757
+ if (new URL(c.req.url).protocol === "https:") return true;
758
+ if (!trustProxy) return false;
759
+ return c.req.header("x-forwarded-proto")?.toLowerCase() === "https";
760
+ }
761
+ function securityHeaders(config) {
762
+ const csp = config.server?.contentSecurityPolicy || DEFAULT_CSP;
763
+ const hsts = config.server?.strictTransportSecurity ?? DEFAULT_HSTS;
764
+ const trustProxy = config.rateLimit?.trustProxy ?? true;
765
+ return async (c, next) => {
766
+ await next();
767
+ c.header("content-security-policy", csp);
768
+ c.header("x-content-type-options", "nosniff");
769
+ if (hsts && (isHttps(c, trustProxy) || process.env.NODE_ENV === "production")) {
770
+ c.header("strict-transport-security", hsts);
771
+ }
772
+ };
684
773
  }
685
774
 
775
+ // src/routes/demo.ts
776
+ import { Hono } from "hono";
777
+ import { stream } from "hono/streaming";
778
+
686
779
  // src/core/session.ts
687
780
  import { randomUUID as randomUUID2 } from "node:crypto";
688
781
  var sessions = /* @__PURE__ */ new Map();
@@ -734,26 +827,94 @@ function getActiveSessions() {
734
827
  return sessions.size;
735
828
  }
736
829
 
830
+ // src/middleware/bodyLimit.ts
831
+ import { bodyLimit as honoBodyLimit } from "hono/body-limit";
832
+ var DEFAULT_MAX_REQUEST_BYTES = 262144;
833
+ function chatBodyLimit(maxSize = DEFAULT_MAX_REQUEST_BYTES, toError = (message) => ({ error: message })) {
834
+ return honoBodyLimit({
835
+ maxSize,
836
+ onError: (c) => c.json(toError("Request body too large"), 413)
837
+ });
838
+ }
839
+
840
+ // src/middleware/clientKey.ts
841
+ function clientRateLimitKey(c, trustProxy) {
842
+ if (!trustProxy) return "shared";
843
+ const header = c.req.header("x-forwarded-for");
844
+ if (!header) return "unknown-ip";
845
+ return header.split(",")[0].trim() || "unknown-ip";
846
+ }
847
+
848
+ // src/middleware/originMatch.ts
849
+ function matchesAllowedOrigin(origin, referer, allowedOrigins) {
850
+ return allowedOrigins.some((allowed) => {
851
+ if (origin === allowed) return true;
852
+ if (referer) {
853
+ if (referer === allowed) return true;
854
+ if (referer.startsWith(`${allowed}/`)) return true;
855
+ if (referer.startsWith(`${allowed}?`)) return true;
856
+ }
857
+ return false;
858
+ });
859
+ }
860
+ function isLocalhost(value) {
861
+ if (!value) return false;
862
+ try {
863
+ const url = new URL(value);
864
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
865
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1";
866
+ } catch {
867
+ return false;
868
+ }
869
+ }
870
+ function matchesAllowedOriginOrLocalhost(origin, referer, allowedOrigins) {
871
+ if (isLocalhost(origin) || isLocalhost(referer)) return true;
872
+ return matchesAllowedOrigin(origin, referer, allowedOrigins);
873
+ }
874
+
875
+ // src/middleware/windowBucket.ts
876
+ function createWindowBucketLimiter(windowMs, now = Date.now) {
877
+ const buckets = /* @__PURE__ */ new Map();
878
+ let lastSweep = now();
879
+ function sweep(current) {
880
+ if (current - lastSweep < windowMs) return;
881
+ lastSweep = current;
882
+ for (const [key, bucket] of buckets) {
883
+ if (current - bucket.windowStart >= windowMs) buckets.delete(key);
884
+ }
885
+ }
886
+ return {
887
+ /** Records one hit for `key` and reports whether it's still within `limit`. */
888
+ hit(key, limit) {
889
+ const current = now();
890
+ sweep(current);
891
+ const bucket = buckets.get(key);
892
+ if (!bucket || current - bucket.windowStart >= windowMs) {
893
+ buckets.set(key, { count: 1, windowStart: current });
894
+ return 1 <= limit;
895
+ }
896
+ bucket.count += 1;
897
+ return bucket.count <= limit;
898
+ },
899
+ /** Current map size — for tests asserting eviction actually happened. */
900
+ size() {
901
+ return buckets.size;
902
+ }
903
+ };
904
+ }
905
+
737
906
  // src/routes/demo.ts
738
- function checkDemoOrigin(allowedOrigins) {
907
+ function checkDemoOrigin(allowedOrigins, allowLocalhost) {
739
908
  const allowAll = allowedOrigins.length === 0 || allowedOrigins.includes("*");
909
+ const matches = allowLocalhost ? matchesAllowedOriginOrLocalhost : matchesAllowedOrigin;
740
910
  return async (c, next) => {
741
- const origin = c.req.header("origin");
742
- const referer = c.req.header("referer");
743
- const host = c.req.header("host") || "";
744
- if (host.startsWith("localhost:") || host.startsWith("127.0.0.1:")) {
745
- return next();
746
- }
747
911
  if (allowAll) {
748
912
  return next();
749
913
  }
750
- if (origin) {
751
- const allowed = allowedOrigins.some((allowed2) => origin.startsWith(allowed2));
752
- if (allowed) return next();
753
- }
754
- if (referer) {
755
- const allowed = allowedOrigins.some((allowed2) => referer.startsWith(allowed2));
756
- if (allowed) return next();
914
+ const origin = c.req.header("origin");
915
+ const referer = c.req.header("referer");
916
+ if (matches(origin, referer, allowedOrigins)) {
917
+ return next();
757
918
  }
758
919
  return c.json(
759
920
  {
@@ -763,22 +924,12 @@ function checkDemoOrigin(allowedOrigins) {
763
924
  );
764
925
  };
765
926
  }
766
- function limitSessionCreation() {
767
- const buckets = /* @__PURE__ */ new Map();
768
- const WINDOW_MS = 36e5;
927
+ function limitSessionCreation(trustProxy) {
928
+ const limiter = createWindowBucketLimiter(36e5);
769
929
  const LIMIT = 10;
770
930
  return async (c, next) => {
771
- const ip = c.req.header("x-forwarded-for")?.split(",")[0].trim() || "unknown-ip";
772
- const key = `session:${ip}`;
773
- const now = Date.now();
774
- const b = buckets.get(key) ?? { count: 0, windowStart: now };
775
- if (now - b.windowStart >= WINDOW_MS) {
776
- b.count = 0;
777
- b.windowStart = now;
778
- }
779
- b.count += 1;
780
- buckets.set(key, b);
781
- if (b.count > LIMIT) {
931
+ const key = `session:${clientRateLimitKey(c, trustProxy)}`;
932
+ if (!limiter.hit(key, LIMIT)) {
782
933
  return c.json(
783
934
  {
784
935
  error: "Session key limit exceeded (10 per hour). Please wait or use your own API key."
@@ -789,22 +940,12 @@ function limitSessionCreation() {
789
940
  await next();
790
941
  };
791
942
  }
792
- function limitDemo() {
793
- const buckets = /* @__PURE__ */ new Map();
794
- const WINDOW_MS = 6e4;
943
+ function limitDemo(trustProxy) {
944
+ const limiter = createWindowBucketLimiter(6e4);
795
945
  const LIMIT = 10;
796
946
  return async (c, next) => {
797
- const ip = c.req.header("x-forwarded-for")?.split(",")[0].trim() || "unknown-ip";
798
- const key = `demo:${ip}`;
799
- const now = Date.now();
800
- const b = buckets.get(key) ?? { count: 0, windowStart: now };
801
- if (now - b.windowStart >= WINDOW_MS) {
802
- b.count = 0;
803
- b.windowStart = now;
804
- }
805
- b.count += 1;
806
- buckets.set(key, b);
807
- if (b.count > LIMIT) {
947
+ const key = `demo:${clientRateLimitKey(c, trustProxy)}`;
948
+ if (!limiter.hit(key, LIMIT)) {
808
949
  return c.json(
809
950
  {
810
951
  error: "Demo rate limit exceeded (10 requests/minute). Please wait or use your own API key."
@@ -816,82 +957,91 @@ function limitDemo() {
816
957
  };
817
958
  }
818
959
  function demoRoutes(deps) {
819
- const { client, store, config, prompts } = deps;
960
+ const { client, store, config, prompts, logger } = deps;
820
961
  const app = new Hono();
821
962
  const demoOrigins = config.server?.allowedOrigins ?? [];
822
- app.get("/api/demo/session", checkDemoOrigin(demoOrigins), limitSessionCreation(), (c) => {
823
- const session = createSession({
824
- maxRequests: 20,
825
- ttl: 3600
826
- // 1 hour
827
- });
828
- console.log(
829
- `[Demo] Created session: ${session.key.slice(0, 20)}... (${session.maxRequests} requests, ${3600}s TTL)`
830
- );
831
- return c.json({
832
- key: session.key,
833
- expiresIn: 3600,
834
- maxRequests: session.maxRequests,
835
- message: "Use this temporary key in the x-api-key header"
836
- });
837
- });
963
+ const allowLocalhostDemo = config.server?.allowLocalhostDemo ?? false;
964
+ const trustProxy = config.rateLimit?.trustProxy ?? true;
965
+ app.get(
966
+ "/api/demo/session",
967
+ checkDemoOrigin(demoOrigins, allowLocalhostDemo),
968
+ limitSessionCreation(trustProxy),
969
+ (c) => {
970
+ const session = createSession({
971
+ maxRequests: 20,
972
+ ttl: 3600
973
+ // 1 hour
974
+ });
975
+ logger.debug(
976
+ `[Demo] Created session: ${session.key.slice(0, 20)}... (${session.maxRequests} requests, ${3600}s TTL)`
977
+ );
978
+ return c.json({
979
+ key: session.key,
980
+ expiresIn: 3600,
981
+ maxRequests: session.maxRequests,
982
+ message: "Use this temporary key in the x-api-key header"
983
+ });
984
+ }
985
+ );
838
986
  app.get("/api/demo/stats", (c) => {
839
987
  return c.json({
840
988
  activeSessions: getActiveSessions()
841
989
  });
842
990
  });
843
- app.post("/api/demo/chat", limitDemo(), async (c) => {
844
- try {
845
- const body = await c.req.json().catch(() => ({}));
846
- const normalized = normalizeChatBody(body);
847
- if (!normalized.ok) {
848
- return c.json({ error: normalized.error }, 400);
849
- }
850
- const { messages } = normalized;
851
- const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
852
- if (!lastUserMsg) {
853
- return c.json({ error: "no user message found in conversation" }, 400);
854
- }
855
- const buckets = await resolveBuckets({ mode: "public", bucketsFor: config.bucketsFor }) ?? defaultBuckets("public");
856
- const ctx = await store.query(lastUserMsg.content, 6, buckets);
857
- const system = [
858
- prompts.baseSystemRules,
859
- prompts.publicPersona,
860
- `Context:
861
- ${ctx.join("\n\n")}`
862
- ].join("\n\n");
863
- const url = new URL(c.req.url);
864
- const wantsStream3 = url.searchParams.get("stream") === "1";
865
- if (wantsStream3) {
866
- return stream(c, async (s) => {
867
- for await (const delta of answerStream({
868
- answerFn: config.answerFn,
869
- client,
870
- system,
871
- messages,
872
- mode: "public"
873
- })) {
874
- await s.write(`data: ${JSON.stringify({ delta })}
991
+ app.post(
992
+ "/api/demo/chat",
993
+ chatBodyLimit(config.server?.maxRequestBytes),
994
+ // Reject oversized bodies first
995
+ limitDemo(trustProxy),
996
+ async (c) => {
997
+ try {
998
+ const body = await c.req.json().catch(() => ({}));
999
+ const normalized = normalizeChatBody(body);
1000
+ if (!normalized.ok) {
1001
+ return c.json({ error: normalized.error }, 400);
1002
+ }
1003
+ const { messages } = normalized;
1004
+ if (!lastUserMessage(messages)) {
1005
+ return c.json({ error: "no user message found in conversation" }, 400);
1006
+ }
1007
+ const buckets = await resolveBuckets({ mode: "public", bucketsFor: config.bucketsFor });
1008
+ const { system } = await prepareChat({ store, prompts, mode: "public", messages, buckets });
1009
+ const model = config.openai.model || DEFAULT_MODEL;
1010
+ const url = new URL(c.req.url);
1011
+ const wantsStream3 = url.searchParams.get("stream") === "1";
1012
+ if (wantsStream3) {
1013
+ return stream(c, async (s) => {
1014
+ for await (const delta of answerStream({
1015
+ answerFn: config.answerFn,
1016
+ client,
1017
+ system,
1018
+ messages,
1019
+ mode: "public",
1020
+ model
1021
+ })) {
1022
+ await s.write(`data: ${JSON.stringify({ delta })}
875
1023
 
876
1024
  `);
877
- }
878
- await s.write("event: end\ndata: {}\n\n");
1025
+ }
1026
+ await s.write("event: end\ndata: {}\n\n");
1027
+ });
1028
+ }
1029
+ const out = await answerOnce({
1030
+ answerFn: config.answerFn,
1031
+ client,
1032
+ system,
1033
+ messages,
1034
+ mode: "public",
1035
+ model
879
1036
  });
1037
+ return c.json({ reply: out.content });
1038
+ } catch (error) {
1039
+ const err = error;
1040
+ logger.error("Demo chat error:", err);
1041
+ return c.json({ error: err.message || "Internal error" }, 500);
880
1042
  }
881
- const out = await answerOnce({
882
- answerFn: config.answerFn,
883
- client,
884
- system,
885
- messages,
886
- mode: "public"
887
- });
888
- return c.json({ reply: out.content });
889
- } catch (error) {
890
- const err = error;
891
- console.error("Demo chat error:", err);
892
- return c.json({ error: err.message || "Internal error" }, 500);
893
1043
  }
894
- });
1044
+ );
895
1045
  return app;
896
1046
  }
897
1047
 
@@ -899,38 +1049,6 @@ ${ctx.join("\n\n")}`
899
1049
  import { Hono as Hono2 } from "hono";
900
1050
  import { stream as stream2 } from "hono/streaming";
901
1051
 
902
- // src/core/pipeline.ts
903
- var MODE_SETTINGS = {
904
- public: { topK: 6, label: "Context" },
905
- private: { topK: 8, label: "Internal Context" }
906
- };
907
- async function prepareChat({
908
- store,
909
- prompts,
910
- mode,
911
- messages,
912
- personaLayer,
913
- channelHint,
914
- buckets
915
- }) {
916
- const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
917
- if (!lastUserMsg) {
918
- throw new Error("no user message found in conversation");
919
- }
920
- const { topK, label } = MODE_SETTINGS[mode];
921
- const ctx = await store.query(lastUserMsg.content, topK, buckets ?? defaultBuckets(mode));
922
- const persona = personaLayer?.trim() || (mode === "private" ? prompts.privatePersona : prompts.publicPersona);
923
- const hint = channelHint?.trim();
924
- const system = [
925
- prompts.baseSystemRules,
926
- persona,
927
- ...hint ? [hint] : [],
928
- `${label}:
929
- ${ctx.join("\n\n")}`
930
- ].join("\n\n");
931
- return { system, messages };
932
- }
933
-
934
1052
  // src/middleware/jwt.ts
935
1053
  import { createRemoteJWKSet, importSPKI, jwtVerify as jwtVerify2 } from "jose";
936
1054
  function jwtSubject(c) {
@@ -957,7 +1075,7 @@ function createJWTMiddleware(config) {
957
1075
  }
958
1076
  throw new Error("JWT configuration missing (JWKS URL or PUBLIC KEY PEM).");
959
1077
  }
960
- return async function requirePrivateJWT2(c, next) {
1078
+ return async function requirePrivateJWT(c, next) {
961
1079
  const hdr = c.req.header("authorization") ?? "";
962
1080
  if (!hdr.startsWith("Bearer ")) return c.json({ error: "Unauthorized" }, 401);
963
1081
  const token = hdr.slice("Bearer ".length);
@@ -970,41 +1088,24 @@ function createJWTMiddleware(config) {
970
1088
  }
971
1089
  };
972
1090
  }
973
- var requirePrivateJWT = createJWTMiddleware({});
974
1091
 
975
1092
  // src/middleware/ratelimit.ts
976
- var DEMO_KEYS = ["chatter-api-key-here"];
977
- function getKeyFromIP(c) {
978
- const h = c.req.header("x-forwarded-for");
979
- if (h) return h.split(",")[0].trim();
980
- return "unknown-ip";
981
- }
982
1093
  function createRateLimiter(config) {
983
- const buckets = /* @__PURE__ */ new Map();
984
- const WINDOW_MS = 6e4;
1094
+ const limiter = createWindowBucketLimiter(6e4);
985
1095
  const publicLimit = config.rateLimit?.public || 60;
986
1096
  const privateLimit = config.rateLimit?.private || 120;
987
- function allow(key, limit) {
988
- const now = Date.now();
989
- const b = buckets.get(key) ?? { count: 0, windowStart: now };
990
- if (now - b.windowStart >= WINDOW_MS) {
991
- b.count = 0;
992
- b.windowStart = now;
993
- }
994
- b.count += 1;
995
- buckets.set(key, b);
996
- return b.count <= limit;
997
- }
1097
+ const trustProxy = config.rateLimit?.trustProxy ?? true;
1098
+ const demoApiKeys = config.rateLimit?.demoApiKeys ?? [];
998
1099
  return {
999
1100
  // Public: key is IP only
1000
1101
  // Demo keys get 10x stricter rate limits
1001
1102
  limitPublic() {
1002
1103
  return async (c, next) => {
1003
1104
  const apiKey = c.req.header("x-api-key");
1004
- const isDemoKey = apiKey ? DEMO_KEYS.includes(apiKey) : false;
1005
- const key = `pub:${getKeyFromIP(c)}`;
1105
+ const isDemoKey = apiKey ? demoApiKeys.includes(apiKey) : false;
1106
+ const key = `pub:${clientRateLimitKey(c, trustProxy)}`;
1006
1107
  const limit = isDemoKey ? Math.floor(publicLimit / 10) : publicLimit;
1007
- if (!allow(key, limit)) {
1108
+ if (!limiter.hit(key, limit)) {
1008
1109
  return c.json(
1009
1110
  {
1010
1111
  error: isDemoKey ? "Demo rate limit exceeded. Please use your own API key for higher limits." : "Rate limit exceeded"
@@ -1020,28 +1121,47 @@ function createRateLimiter(config) {
1020
1121
  return async (c, next) => {
1021
1122
  const sub = c.jwtSub;
1022
1123
  const key = `private:${sub ?? "no-sub"}`;
1023
- if (!allow(key, privateLimit)) return c.json({ error: "Rate limit" }, 429);
1124
+ if (!limiter.hit(key, privateLimit)) return c.json({ error: "Rate limit" }, 429);
1024
1125
  await next();
1025
1126
  };
1026
1127
  }
1027
1128
  };
1028
1129
  }
1029
- var defaultLimiter = createRateLimiter({});
1030
- var limitPublic = defaultLimiter.limitPublic;
1031
- var limitPrivate = defaultLimiter.limitPrivate;
1032
1130
 
1033
1131
  // src/routes/openai.ts
1132
+ function apiKeySubject(c) {
1133
+ const sub = c.apiKeySub;
1134
+ return sub && sub.trim().length > 0 ? sub : void 0;
1135
+ }
1136
+ function payloadSub(payload) {
1137
+ const sub = payload?.sub;
1138
+ return typeof sub === "string" ? sub : void 0;
1139
+ }
1140
+ var CONVERSATION_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,200}$/;
1141
+ function sanitizeConversationId(value) {
1142
+ const trimmed = value?.trim();
1143
+ return trimmed && CONVERSATION_ID_PATTERN.test(trimmed) ? trimmed : void 0;
1144
+ }
1145
+ function clientConversationId(c, body) {
1146
+ const header = sanitizeConversationId(c.req.header("x-conversation-id"));
1147
+ if (header) return header;
1148
+ const fromBody = body?.conversation_id;
1149
+ return sanitizeConversationId(typeof fromBody === "string" ? fromBody : void 0);
1150
+ }
1034
1151
  function errorJson(c, status, message) {
1035
1152
  return c.json(
1036
1153
  { error: { message, type: status === 401 ? "authentication_error" : "invalid_request_error" } },
1037
1154
  status
1038
1155
  );
1039
1156
  }
1157
+ function oversizedBodyError(message) {
1158
+ return { error: { message, type: "invalid_request_error" } };
1159
+ }
1040
1160
  function openaiRoutes(deps) {
1041
1161
  const { client, store, config, prompts, apiKeyManager } = deps;
1042
1162
  const app = new Hono2();
1043
- const { limitPublic: limitPublic2, limitPrivate: limitPrivate2 } = createRateLimiter(config);
1044
- const requirePrivateJWT2 = createJWTMiddleware(config);
1163
+ const { limitPublic, limitPrivate } = createRateLimiter(config);
1164
+ const requirePrivateJWT = createJWTMiddleware(config);
1045
1165
  const enablePublic = config.features?.enablePublicChat !== false;
1046
1166
  const enablePrivate = config.features?.enablePrivateChat !== false;
1047
1167
  const serverModel = config.openai.model || DEFAULT_MODEL;
@@ -1056,7 +1176,10 @@ function openaiRoutes(deps) {
1056
1176
  }
1057
1177
  try {
1058
1178
  const result = await apiKeyManager.verify(key);
1059
- if (result.valid) return await next();
1179
+ if (result.valid) {
1180
+ c.apiKeySub = payloadSub(result.payload);
1181
+ return await next();
1182
+ }
1060
1183
  } catch {
1061
1184
  }
1062
1185
  return errorJson(c, 401, "Invalid API key");
@@ -1083,6 +1206,9 @@ function openaiRoutes(deps) {
1083
1206
  } catch {
1084
1207
  return errorJson(c, 400, "Conversation must contain at least one user message");
1085
1208
  }
1209
+ const sender = jwtSubject(c) ?? apiKeySubject(c);
1210
+ const conversationId = clientConversationId(c, body) ?? crypto.randomUUID();
1211
+ c.header("x-conversation-id", conversationId);
1086
1212
  const id = `chatcmpl-${crypto.randomUUID()}`;
1087
1213
  const created = Math.floor(Date.now() / 1e3);
1088
1214
  if (wantsStream3 === true) {
@@ -1105,6 +1231,8 @@ function openaiRoutes(deps) {
1105
1231
  system,
1106
1232
  messages,
1107
1233
  mode,
1234
+ sender,
1235
+ conversationId,
1108
1236
  temperature,
1109
1237
  model: serverModel
1110
1238
  })) {
@@ -1120,6 +1248,8 @@ function openaiRoutes(deps) {
1120
1248
  system,
1121
1249
  messages,
1122
1250
  mode,
1251
+ sender,
1252
+ conversationId,
1123
1253
  temperature,
1124
1254
  model: serverModel
1125
1255
  });
@@ -1135,13 +1265,15 @@ function openaiRoutes(deps) {
1135
1265
  });
1136
1266
  };
1137
1267
  if (enablePublic) {
1268
+ app.use("/v1/*", chatBodyLimit(config.server?.maxRequestBytes, oversizedBodyError));
1138
1269
  app.use("/v1/*", requireApiKey);
1139
- app.use("/v1/*", limitPublic2());
1270
+ app.use("/v1/*", limitPublic());
1140
1271
  app.post("/v1/chat/completions", handleChatCompletions("public"));
1141
1272
  }
1142
1273
  if (enablePrivate) {
1143
- app.use("/api/private/v1/*", requirePrivateJWT2);
1144
- app.use("/api/private/v1/*", limitPrivate2());
1274
+ app.use("/api/private/v1/*", chatBodyLimit(config.server?.maxRequestBytes, oversizedBodyError));
1275
+ app.use("/api/private/v1/*", requirePrivateJWT);
1276
+ app.use("/api/private/v1/*", limitPrivate());
1145
1277
  app.post("/api/private/v1/chat/completions", handleChatCompletions("private"));
1146
1278
  }
1147
1279
  return app;
@@ -1159,10 +1291,11 @@ function wantsStream(c) {
1159
1291
  function privateRoutes(deps) {
1160
1292
  const { client, store, config, prompts } = deps;
1161
1293
  const app = new Hono3();
1162
- const requirePrivateJWT2 = createJWTMiddleware(config);
1163
- const { limitPrivate: limitPrivate2 } = createRateLimiter(config);
1164
- app.use("/api/private/*", requirePrivateJWT2);
1165
- app.use("/api/private/*", limitPrivate2());
1294
+ const requirePrivateJWT = createJWTMiddleware(config);
1295
+ const { limitPrivate } = createRateLimiter(config);
1296
+ app.use("/api/private/*", chatBodyLimit(config.server?.maxRequestBytes));
1297
+ app.use("/api/private/*", requirePrivateJWT);
1298
+ app.use("/api/private/*", limitPrivate());
1166
1299
  app.post("/api/private/chat", async (c) => {
1167
1300
  const body = await c.req.json().catch(() => ({}));
1168
1301
  const normalized = normalizeChatBody(body);
@@ -1170,9 +1303,10 @@ function privateRoutes(deps) {
1170
1303
  return c.json({ error: normalized.error }, 400);
1171
1304
  }
1172
1305
  const { messages } = normalized;
1306
+ const sender = jwtSubject(c);
1173
1307
  const buckets = await resolveBuckets({
1174
1308
  mode: "private",
1175
- sender: jwtSubject(c),
1309
+ sender,
1176
1310
  bucketsFor: config.bucketsFor
1177
1311
  });
1178
1312
  let system;
@@ -1191,6 +1325,7 @@ function privateRoutes(deps) {
1191
1325
  system,
1192
1326
  messages,
1193
1327
  mode: "private",
1328
+ sender,
1194
1329
  model
1195
1330
  })) {
1196
1331
  await s.write(`data: ${JSON.stringify({ delta })}
@@ -1200,7 +1335,15 @@ function privateRoutes(deps) {
1200
1335
  await s.write("event: end\ndata: {}\n\n");
1201
1336
  });
1202
1337
  }
1203
- const out = await answerOnce({ answerFn, client, system, messages, mode: "private", model });
1338
+ const out = await answerOnce({
1339
+ answerFn,
1340
+ client,
1341
+ system,
1342
+ messages,
1343
+ mode: "private",
1344
+ sender,
1345
+ model
1346
+ });
1204
1347
  return c.json({ reply: out.content });
1205
1348
  });
1206
1349
  return app;
@@ -1212,64 +1355,55 @@ import { stream as stream4 } from "hono/streaming";
1212
1355
 
1213
1356
  // src/middleware/auth.ts
1214
1357
  function createAuthMiddleware(deps) {
1215
- const { apiKeyManager } = deps;
1358
+ const { apiKeyManager, logger } = deps;
1216
1359
  return async function requirePublicKey(c, next) {
1217
1360
  const hdr = c.req.header("x-api-key");
1218
1361
  if (hdr?.startsWith("session_")) {
1219
- console.log(`[Auth] Session key allowed: ${hdr.slice(0, 20)}...`);
1362
+ logger.debug(`[Auth] Session key allowed: ${hdr.slice(0, 20)}...`);
1220
1363
  await next();
1221
1364
  return;
1222
1365
  }
1223
1366
  if (!apiKeyManager) {
1224
- console.log("[Auth] No API key manager configured");
1367
+ logger.debug("[Auth] No API key manager configured");
1225
1368
  return c.json({ error: "Unauthorized - API key authentication not configured" }, 401);
1226
1369
  }
1227
1370
  if (!hdr) {
1228
- console.log("[Auth] No API key provided");
1371
+ logger.debug("[Auth] No API key provided");
1229
1372
  return c.json({ error: "Unauthorized - Missing x-api-key header" }, 401);
1230
1373
  }
1231
1374
  try {
1232
1375
  const result = await apiKeyManager.verify(hdr);
1233
1376
  if (result.valid) {
1234
1377
  const payload = result.payload;
1235
- console.log(`[Auth] JWT API key authorized: ${payload.name || payload.sub || "unknown"}`);
1378
+ logger.debug(`[Auth] JWT API key authorized: ${payload.name || payload.sub || "unknown"}`);
1236
1379
  await next();
1237
1380
  return;
1238
1381
  }
1239
- console.log(`[Auth] JWT verification failed: ${result.error}`);
1382
+ logger.debug(`[Auth] JWT verification failed: ${result.error}`);
1240
1383
  } catch (error) {
1241
- console.log(`[Auth] JWT verification error: ${error}`);
1384
+ logger.warn(`[Auth] JWT verification error: ${error}`);
1242
1385
  }
1243
- console.log(`[Auth] Invalid API key: ${hdr?.slice(0, 20) || "none"}`);
1386
+ logger.debug(`[Auth] Invalid API key: ${hdr?.slice(0, 20) || "none"}`);
1244
1387
  return c.json({ error: "Unauthorized" }, 401);
1245
1388
  };
1246
1389
  }
1247
1390
 
1248
1391
  // src/middleware/referrer.ts
1249
- var DEMO_KEYS2 = ["chatter-api-key-here"];
1250
- function requireReferrer(allowedOrigins) {
1392
+ function requireReferrer(allowedOrigins, demoApiKeys = [], logger = createConsoleLogger()) {
1251
1393
  return async (c, next) => {
1252
1394
  const apiKey = c.req.header("x-api-key");
1253
- const isDemoKey = apiKey && DEMO_KEYS2.includes(apiKey);
1395
+ const isDemoKey = apiKey ? demoApiKeys.includes(apiKey) : false;
1254
1396
  const isSessionKey = apiKey?.startsWith("session_");
1255
1397
  if (isDemoKey || isSessionKey) {
1256
1398
  const referer = c.req.header("referer");
1257
1399
  const origin = c.req.header("origin");
1258
- console.log(
1400
+ logger.debug(
1259
1401
  `[Referrer] Checking ${isSessionKey ? "session" : "demo"} key: ${apiKey?.slice(0, 20)}...`
1260
1402
  );
1261
- console.log(`[Referrer] Origin: ${origin || "none"}, Referer: ${referer || "none"}`);
1262
- const isAllowed = allowedOrigins.some((allowed) => {
1263
- if (origin === allowed) return true;
1264
- if (referer) {
1265
- if (referer === allowed) return true;
1266
- if (referer.startsWith(`${allowed}/`)) return true;
1267
- if (referer.startsWith(`${allowed}?`)) return true;
1268
- }
1269
- return false;
1270
- });
1403
+ logger.debug(`[Referrer] Origin: ${origin || "none"}, Referer: ${referer || "none"}`);
1404
+ const isAllowed = matchesAllowedOrigin(origin, referer, allowedOrigins);
1271
1405
  if (!isAllowed) {
1272
- console.log(`[Referrer] BLOCKED - not from allowed origins: ${allowedOrigins.join(", ")}`);
1406
+ logger.debug(`[Referrer] BLOCKED - not from allowed origins: ${allowedOrigins.join(", ")}`);
1273
1407
  return c.json(
1274
1408
  {
1275
1409
  error: "Demo API keys only work on official demo pages. Please visit our website or use your own API key."
@@ -1277,29 +1411,29 @@ function requireReferrer(allowedOrigins) {
1277
1411
  403
1278
1412
  );
1279
1413
  }
1280
- console.log("[Referrer] Allowed - origin matches");
1414
+ logger.debug("[Referrer] Allowed - origin matches");
1281
1415
  }
1282
1416
  await next();
1283
1417
  };
1284
1418
  }
1285
1419
 
1286
1420
  // src/middleware/session.ts
1287
- function validateSessionKey() {
1421
+ function validateSessionKey(logger = createConsoleLogger()) {
1288
1422
  return async (c, next) => {
1289
1423
  const apiKey = c.req.header("x-api-key");
1290
1424
  if (apiKey?.startsWith("session_")) {
1291
- console.log(`[Session] Validating key: ${apiKey.slice(0, 20)}...`);
1425
+ logger.debug(`[Session] Validating key: ${apiKey.slice(0, 20)}...`);
1292
1426
  const info = getSessionInfo(apiKey);
1293
1427
  if (info) {
1294
- console.log(
1428
+ logger.debug(
1295
1429
  `[Session] Key found - requests: ${info.requests}/${info.maxRequests}, expires: ${new Date(info.expiresAt).toISOString()}`
1296
1430
  );
1297
1431
  } else {
1298
- console.log("[Session] Key not found in session store");
1432
+ logger.debug("[Session] Key not found in session store");
1299
1433
  }
1300
1434
  const isValid = validateSession(apiKey);
1301
1435
  if (!isValid) {
1302
- console.log("[Session] REJECTED - expired or quota exceeded");
1436
+ logger.debug("[Session] REJECTED - expired or quota exceeded");
1303
1437
  return c.json(
1304
1438
  {
1305
1439
  error: "Session expired or quota exceeded. Please refresh the page to get a new session."
@@ -1307,7 +1441,7 @@ function validateSessionKey() {
1307
1441
  401
1308
1442
  );
1309
1443
  }
1310
- console.log("[Session] Valid - request count incremented");
1444
+ logger.debug("[Session] Valid - request count incremented");
1311
1445
  }
1312
1446
  await next();
1313
1447
  };
@@ -1321,15 +1455,19 @@ function wantsStream2(c) {
1321
1455
  return acc.includes("text/event-stream");
1322
1456
  }
1323
1457
  function publicRoutes(deps) {
1324
- const { client, store, config, prompts } = deps;
1458
+ const { client, store, config, prompts, logger } = deps;
1325
1459
  const app = new Hono4();
1326
1460
  const requirePublicKey = createAuthMiddleware(deps);
1327
- const { limitPublic: limitPublic2 } = createRateLimiter(config);
1461
+ const { limitPublic } = createRateLimiter(config);
1328
1462
  const referrerOrigins = config.server?.allowedOrigins?.length ? config.server.allowedOrigins.filter((o) => o !== "*") : [config.bot.publicUrl, "http://localhost:8181", "http://127.0.0.1:8181"];
1329
- app.use("/api/public/*", validateSessionKey());
1463
+ app.use("/api/public/*", chatBodyLimit(config.server?.maxRequestBytes));
1464
+ app.use("/api/public/*", validateSessionKey(logger));
1330
1465
  app.use("/api/public/*", requirePublicKey);
1331
- app.use("/api/public/*", requireReferrer(referrerOrigins));
1332
- app.use("/api/public/*", limitPublic2());
1466
+ app.use(
1467
+ "/api/public/*",
1468
+ requireReferrer(referrerOrigins, config.rateLimit?.demoApiKeys ?? [], logger)
1469
+ );
1470
+ app.use("/api/public/*", limitPublic());
1333
1471
  app.post("/api/public/chat", async (c) => {
1334
1472
  const body = await c.req.json().catch(() => ({}));
1335
1473
  const normalized = normalizeChatBody(body);
@@ -1371,7 +1509,8 @@ function publicRoutes(deps) {
1371
1509
 
1372
1510
  // src/server.ts
1373
1511
  async function createServer(config) {
1374
- console.log(`\u{1F680} Starting ${config.bot.name}...`);
1512
+ const logger = resolveLogger(config.logger, config.logLevel);
1513
+ logger.info(`\u{1F680} Starting ${config.bot.name}...`);
1375
1514
  const knowledgeDir = config.knowledgeDir || "./config/knowledge";
1376
1515
  const promptsDir = config.promptsDir || "./config/prompts";
1377
1516
  const publicDir = config.publicDir || "./public";
@@ -1390,24 +1529,26 @@ async function createServer(config) {
1390
1529
  });
1391
1530
  const store = new VectorStore(client, {
1392
1531
  databaseClient: db,
1393
- knowledgeDir
1532
+ knowledgeDir,
1533
+ logger
1394
1534
  });
1395
1535
  await store.build();
1396
- console.log("\u2705 Knowledge base ready");
1536
+ logger.info("\u2705 Knowledge base ready");
1397
1537
  const prompts = new PromptLoader(promptsDir, config.bot);
1398
1538
  let apiKeyManager;
1399
- const secret = process.env.CHATTER_SECRET || config.auth?.secret;
1539
+ const secret = config.auth?.secret || process.env.CHATTER_SECRET;
1400
1540
  if (secret) {
1401
1541
  apiKeyManager = new ApiKeyManager(secret);
1402
- console.log("\u2705 API key manager initialized");
1542
+ logger.info("\u2705 API key manager initialized");
1403
1543
  } else if (config.auth?.apiKeyManager) {
1404
1544
  apiKeyManager = config.auth.apiKeyManager;
1405
- console.log("\u2705 Custom API key manager configured");
1545
+ logger.info("\u2705 Custom API key manager configured");
1406
1546
  }
1407
1547
  const app = new Hono5();
1408
1548
  if (config.server?.cors !== false) {
1409
1549
  app.use("*", cors(config.server?.allowedOrigins));
1410
1550
  }
1551
+ app.use("*", securityHeaders(config));
1411
1552
  app.get("/healthz", (c) => c.text("ok"));
1412
1553
  app.get(
1413
1554
  "/config",
@@ -1425,7 +1566,7 @@ async function createServer(config) {
1425
1566
  try {
1426
1567
  serveStatic = await loadServeStatic();
1427
1568
  } catch (error) {
1428
- console.error(`\u274C Static assets disabled: ${error.message}`);
1569
+ logger.error(`\u274C Static assets disabled: ${error.message}`);
1429
1570
  }
1430
1571
  }
1431
1572
  if (serveStatic && staticDir) {
@@ -1475,8 +1616,17 @@ async function createServer(config) {
1475
1616
  app.get("/demo/chatbot", serveStatic({ path: `${publicDir}/demo-chatbot.html` }));
1476
1617
  app.get("/demo/react", serveStatic({ path: `${publicDir}/react-demo.html` }));
1477
1618
  }
1478
- const senders = createSenderRegistry();
1479
- const deps = { client, store, db, config, prompts, apiKeyManager, senders };
1619
+ const senders = createSenderRegistry(logger);
1620
+ const deps = {
1621
+ client,
1622
+ store,
1623
+ db,
1624
+ config,
1625
+ prompts,
1626
+ apiKeyManager,
1627
+ senders,
1628
+ logger
1629
+ };
1480
1630
  if (enablePublic) {
1481
1631
  app.route("/", publicRoutes(deps));
1482
1632
  }
@@ -1498,13 +1648,13 @@ async function createServer(config) {
1498
1648
  try {
1499
1649
  await channel.start(deps);
1500
1650
  started.push(channel);
1501
- console.log(`\u2705 Channel "${channel.name}" started`);
1651
+ logger.info(`\u2705 Channel "${channel.name}" started`);
1502
1652
  } catch (error) {
1503
- console.error(`\u274C Channel "${channel.name}" failed to start:`, error);
1653
+ logger.error(`\u274C Channel "${channel.name}" failed to start:`, error);
1504
1654
  }
1505
1655
  }
1506
1656
  }
1507
- console.log(`\u2705 ${config.bot.name} server ready`);
1657
+ logger.info(`\u2705 ${config.bot.name} server ready`);
1508
1658
  return Object.assign(app, {
1509
1659
  // Scoped to this call's started channels, not global — two servers in
1510
1660
  // one process each stop only their own. A channel's `stop()` throwing
@@ -1516,7 +1666,7 @@ async function createServer(config) {
1516
1666
  try {
1517
1667
  await channel.stop?.();
1518
1668
  } catch (error) {
1519
- console.error(`Channel "${channel.name}" failed to stop:`, error);
1669
+ logger.error(`Channel "${channel.name}" failed to stop:`, error);
1520
1670
  }
1521
1671
  })
1522
1672
  );