@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.
- package/README.md +3 -4
- 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 +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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
+
this.logger.info("\u2705 No new chunks to embed - knowledge base is up to date");
|
|
320
576
|
return;
|
|
321
577
|
}
|
|
322
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
return messages.length > 0 ? messages : null;
|
|
685
|
+
);
|
|
686
|
+
cache.set(runtime, loading);
|
|
687
|
+
return loading;
|
|
663
688
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
711
|
+
} catch {
|
|
678
712
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
|
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
|
|
772
|
-
|
|
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
|
|
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
|
|
798
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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(
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
988
|
-
|
|
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 ?
|
|
1005
|
-
const key = `pub:${
|
|
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 (!
|
|
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 (!
|
|
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
|
|
1044
|
-
const
|
|
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)
|
|
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/*",
|
|
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/*",
|
|
1144
|
-
app.use("/api/private/v1/*",
|
|
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
|
|
1163
|
-
const { limitPrivate
|
|
1164
|
-
app.use("/api/private/*",
|
|
1165
|
-
app.use("/api/private/*",
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1378
|
+
logger.debug(`[Auth] JWT API key authorized: ${payload.name || payload.sub || "unknown"}`);
|
|
1236
1379
|
await next();
|
|
1237
1380
|
return;
|
|
1238
1381
|
}
|
|
1239
|
-
|
|
1382
|
+
logger.debug(`[Auth] JWT verification failed: ${result.error}`);
|
|
1240
1383
|
} catch (error) {
|
|
1241
|
-
|
|
1384
|
+
logger.warn(`[Auth] JWT verification error: ${error}`);
|
|
1242
1385
|
}
|
|
1243
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1400
|
+
logger.debug(
|
|
1259
1401
|
`[Referrer] Checking ${isSessionKey ? "session" : "demo"} key: ${apiKey?.slice(0, 20)}...`
|
|
1260
1402
|
);
|
|
1261
|
-
|
|
1262
|
-
const isAllowed =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1425
|
+
logger.debug(`[Session] Validating key: ${apiKey.slice(0, 20)}...`);
|
|
1292
1426
|
const info = getSessionInfo(apiKey);
|
|
1293
1427
|
if (info) {
|
|
1294
|
-
|
|
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
|
-
|
|
1432
|
+
logger.debug("[Session] Key not found in session store");
|
|
1299
1433
|
}
|
|
1300
1434
|
const isValid = validateSession(apiKey);
|
|
1301
1435
|
if (!isValid) {
|
|
1302
|
-
|
|
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
|
-
|
|
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
|
|
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/*",
|
|
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(
|
|
1332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1539
|
+
const secret = config.auth?.secret || process.env.CHATTER_SECRET;
|
|
1400
1540
|
if (secret) {
|
|
1401
1541
|
apiKeyManager = new ApiKeyManager(secret);
|
|
1402
|
-
|
|
1542
|
+
logger.info("\u2705 API key manager initialized");
|
|
1403
1543
|
} else if (config.auth?.apiKeyManager) {
|
|
1404
1544
|
apiKeyManager = config.auth.apiKeyManager;
|
|
1405
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
1651
|
+
logger.info(`\u2705 Channel "${channel.name}" started`);
|
|
1502
1652
|
} catch (error) {
|
|
1503
|
-
|
|
1653
|
+
logger.error(`\u274C Channel "${channel.name}" failed to start:`, error);
|
|
1504
1654
|
}
|
|
1505
1655
|
}
|
|
1506
1656
|
}
|
|
1507
|
-
|
|
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
|
-
|
|
1669
|
+
logger.error(`Channel "${channel.name}" failed to stop:`, error);
|
|
1520
1670
|
}
|
|
1521
1671
|
})
|
|
1522
1672
|
);
|