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