From 5c34552ba34ecd4f5e261663fc82800ad27c0141 Mon Sep 17 00:00:00 2001 From: Anuj Jain Date: Tue, 20 Oct 2026 11:03:19 +0530 Subject: [PATCH 01/80] docs: fix the widget size and i18n claims in the READMEs (#206) * Fixes #83 * Fixes #183 * Update README.md Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> * Update packages/widget/README.md Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> --------- Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> --- README.md | 4 ++-- packages/widget/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 76bafe5c..e69677f8 100644 --- a/README.md +++ b/README.md @@ -51,13 +51,13 @@ That's it. Your site now has an AI chat widget. ## Features - **Multi-provider** - Claude, OpenAI, Gemini. Switch with one config change. -- **Zero-dependency widget** - Shadow DOM isolated, ~50KB gzipped. +- **Zero-dependency widget** - Shadow DOM isolated, ~13KB gzipped. - **Streaming responses** - Real-time SSE streaming from all providers. - **Lead capture** - Built-in tool for capturing contact info, with webhook support. - **Knowledge base** - Feed text chunks or FAQ pairs for context-aware responses. - **Conversation persistence** - localStorage on client, pluggable stores on server. - **Analytics** - Track conversations, messages, and lead captures. -- **i18n** - Replace any widget string, and the model replies in the visitor's language. +- **i18n** - Ships in English with custom string overrides; the model replies in the visitor's language. - **Deployable anywhere** - Express, Vercel, Cloudflare Workers adapters included. - **Customizable** - Theme, branding, position, welcome messages, callbacks. diff --git a/packages/widget/README.md b/packages/widget/README.md index ce79d8aa..cd1e688b 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -1,6 +1,6 @@ # @chatcops/widget -Embeddable AI chatbot widget. Zero dependencies, Shadow DOM isolated, ~50KB gzipped. +Embeddable AI chatbot widget. Zero dependencies, Shadow DOM isolated, ~13KB gzipped. ## Install @@ -157,7 +157,7 @@ All configuration can be set via `data-*` attributes: - **Conversation Persistence** - localStorage for history, sessionStorage for session ID - **Theming** - CSS custom properties, auto-derived surface colors from `bgColor` - **Mobile Responsive** - Full-screen panel on screens below 768px -- **i18n** - Built-in locale strings with custom override support +- **i18n** - Ships in English with custom override support for any language - **Accessible** - ARIA labels, semantic HTML, keyboard navigation ## License From af10c012156c2bfd347e137ca7d39ec4d5811ade Mon Sep 17 00:00:00 2001 From: Bhushan Agrawal Date: Tue, 20 Oct 2026 11:06:19 +0530 Subject: [PATCH 02/80] chore: add Biome lint, run it in CI, and fix what it reports (#209) * chore: add biome linter and fix genuine lint violations (#191) * chore: add empty changeset for internal lint improvements (#191) --------- Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> --- .changeset/real-words-refuse.md | 2 + .github/workflows/ci.yml | 3 + biome.json | 73 +++++++++++++++ package.json | 3 +- packages/core/src/providers/openai.ts | 4 +- packages/core/tests/providers/claude.test.ts | 4 +- packages/server/src/adapters/express.ts | 2 +- packages/server/src/visitor-context.ts | 1 + packages/server/tests/abort.test.ts | 3 +- .../server/tests/adapters/express.test.ts | 3 +- packages/server/tests/handler.test.ts | 2 +- packages/widget/src/dom/fab.ts | 2 - packages/widget/src/dom/messages.ts | 4 +- packages/widget/src/dom/prechat-form.ts | 1 - packages/widget/src/markdown.ts | 1 + packages/widget/src/namespace.ts | 3 +- packages/widget/src/widget.ts | 4 +- .../widget/tests/dom/prechat-form.test.ts | 1 - packages/widget/tests/index.test.ts | 4 +- pnpm-lock.yaml | 91 +++++++++++++++++++ website/src/assets/chatcops-logo.svg | 1 + website/src/components/react/CodeTabs.tsx | 2 + website/src/lib/upstash.ts | 4 +- 23 files changed, 199 insertions(+), 19 deletions(-) create mode 100644 .changeset/real-words-refuse.md create mode 100644 biome.json diff --git a/.changeset/real-words-refuse.md b/.changeset/real-words-refuse.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/real-words-refuse.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec1ba1b5..61c25788 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + - name: Build run: pnpm -r build diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..22b33f42 --- /dev/null +++ b/biome.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": false, + "indentStyle": "space", + "indentWidth": 2 + }, + "assist": { + "enabled": false + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "all" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "overrides": [ + { + "includes": ["**/tests/**"], + "linter": { + "rules": { + "security": { + "noGlobalEval": "off" + }, + "correctness": { + "useYield": "off" + } + } + } + }, + { + "includes": ["**/*.astro"], + "linter": { + "rules": { + "correctness": { + "noUnusedImports": "off", + "noUnusedVariables": "off" + } + } + } + }, + { + "includes": ["**/starlight-overrides.css"], + "linter": { + "rules": { + "complexity": { + "noImportantStyles": "off" + } + } + } + } + ] +} diff --git a/package.json b/package.json index 60b5c9bc..a3738722 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,13 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "pnpm -r typecheck", - "lint": "pnpm -r lint", + "lint": "biome check packages/*/src packages/*/tests website/src", "clean": "pnpm -r clean", "e2e": "playwright test -c e2e/playwright.config.ts" }, "devDependencies": { "@axe-core/playwright": "^4.14.0", + "@biomejs/biome": "^2.5.15", "@changesets/cli": "^2.27.0", "@playwright/test": "^1.64.1", "typescript": "^5.7.0", diff --git a/packages/core/src/providers/openai.ts b/packages/core/src/providers/openai.ts index 3dfda0f0..dadf7846 100644 --- a/packages/core/src/providers/openai.ts +++ b/packages/core/src/providers/openai.ts @@ -1,5 +1,5 @@ import OpenAI from 'openai'; -import type { ProviderConfig, ProviderChatParams, ProviderEffort } from '../types.js'; +import type { ProviderConfig, ProviderChatParams, ProviderEffort, ToolResult } from '../types.js'; import type { AIProvider } from './base.js'; import { DEFAULT_MAX_TOKENS, normalizeHistory, toOpenAITools, warnOnce } from './base.js'; import { @@ -195,7 +195,7 @@ export class OpenAIProvider implements AIProvider { toolCalls: FunctionToolCall[] ): Promise { return Promise.all(toolCalls.map(async (toolCall) => { - let result; + let result: ToolResult; try { result = await executeToolCall(params, { id: toolCall.id, diff --git a/packages/core/tests/providers/claude.test.ts b/packages/core/tests/providers/claude.test.ts index 3db268c5..12ec2520 100644 --- a/packages/core/tests/providers/claude.test.ts +++ b/packages/core/tests/providers/claude.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { toClaudeTools, toProviderMessages } from '../../src/providers/base.js'; -import type { ChatMessage, ToolDefinition } from '../../src/types.js'; +import type { ChatMessage } from '../../src/types.js'; describe('Claude provider helpers', () => { it('converts messages, filtering out system role', () => { diff --git a/packages/server/src/adapters/express.ts b/packages/server/src/adapters/express.ts index 45a3ded5..1204fa86 100644 --- a/packages/server/src/adapters/express.ts +++ b/packages/server/src/adapters/express.ts @@ -24,7 +24,7 @@ export function chatcopsMiddleware(config: ChatCopsServerConfig) { res.status(status).json({ error }); } - return async function chatcopsHandler(req: Request, res: Response, next: NextFunction) { + return async function chatcopsHandler(req: Request, res: Response, _next: NextFunction) { // Handle CORS preflight if (req.method === 'OPTIONS') { res.setHeader('Access-Control-Allow-Origin', corsOrigin); diff --git a/packages/server/src/visitor-context.ts b/packages/server/src/visitor-context.ts index 11980a45..ed3fd438 100644 --- a/packages/server/src/visitor-context.ts +++ b/packages/server/src/visitor-context.ts @@ -19,6 +19,7 @@ const TAG = 'visitor_context'; /** One line of plain text: no control characters, no line breaks, no tags that could close the block. */ function clean(value: string): string { return value + // biome-ignore lint/suspicious/noControlCharactersInRegex: sanitize control characters .replace(/[\u0000-\u001f\u007f]+/g, ' ') .replace(new RegExp(`<\\s*/?\\s*${TAG}[^>]*>`, 'gi'), '') .replace(/\s+/g, ' ') diff --git a/packages/server/tests/abort.test.ts b/packages/server/tests/abort.test.ts index 03916988..e08cf268 100644 --- a/packages/server/tests/abort.test.ts +++ b/packages/server/tests/abort.test.ts @@ -132,7 +132,8 @@ describe('adapters stop the provider when the visitor disconnects', () => { ended = true; }), on: vi.fn((event: string, listener: () => void) => { - (listeners[event] ??= []).push(listener); + listeners[event] ??= []; + listeners[event].push(listener); }), }; const req = { method: 'POST', body, ip: '127.0.0.1', socket: {} }; diff --git a/packages/server/tests/adapters/express.test.ts b/packages/server/tests/adapters/express.test.ts index 0fe6320c..ff3a8d1e 100644 --- a/packages/server/tests/adapters/express.test.ts +++ b/packages/server/tests/adapters/express.test.ts @@ -84,7 +84,8 @@ function createResponse(): { fake: FakeResponse; res: Response } { }, writableFinished: false, on: (event, listener) => { - (listeners[event] ??= []).push(listener); + listeners[event] ??= []; + listeners[event].push(listener); return fake; }, disconnect: () => { diff --git a/packages/server/tests/handler.test.ts b/packages/server/tests/handler.test.ts index 14574f81..3b2a46b1 100644 --- a/packages/server/tests/handler.test.ts +++ b/packages/server/tests/handler.test.ts @@ -181,7 +181,7 @@ describe('createChatHandler', () => { input: { city: 'Boston' }, }); - yield `Forecast: ${(toolResult?.data as { forecast: string }).forecast}`; + yield `Forecast: ${(toolResult?.data as { forecast?: string })?.forecast}`; }, async chatSync() { return ''; diff --git a/packages/widget/src/dom/fab.ts b/packages/widget/src/dom/fab.ts index a0259728..6ba7ba29 100644 --- a/packages/widget/src/dom/fab.ts +++ b/packages/widget/src/dom/fab.ts @@ -13,7 +13,6 @@ export interface FABOptions { export class FAB { private el: HTMLButtonElement; private badge: HTMLDivElement; - private isOpen = false; private openLabel: string; private closeLabel: string; @@ -44,7 +43,6 @@ export class FAB { } setOpen(open: boolean): void { - this.isOpen = open; this.el.innerHTML = open ? CLOSE_ICON : CHAT_ICON; if (!open) this.el.appendChild(this.badge); this.el.classList.toggle('cc-open', open); diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts index cfb76f9f..16d88a3b 100644 --- a/packages/widget/src/dom/messages.ts +++ b/packages/widget/src/dom/messages.ts @@ -81,7 +81,9 @@ export class Messages { } clearRegenerateButtons(): void { - this.container.querySelectorAll('.cc-message-regenerate').forEach((button) => button.remove()); + this.container.querySelectorAll('.cc-message-regenerate').forEach((button) => { + button.remove(); + }); this.cleanupEmptyActionContainers(); } diff --git a/packages/widget/src/dom/prechat-form.ts b/packages/widget/src/dom/prechat-form.ts index 49226df9..e6d74c6d 100644 --- a/packages/widget/src/dom/prechat-form.ts +++ b/packages/widget/src/dom/prechat-form.ts @@ -177,7 +177,6 @@ export class PreChatForm { if (field.type === 'email' && value && !this.isValidEmail(value)) { this.showError(field.name, this.options.locale.preChatInvalidEmail); valid = false; - continue; } } diff --git a/packages/widget/src/markdown.ts b/packages/widget/src/markdown.ts index 9bf8413b..838b224d 100644 --- a/packages/widget/src/markdown.ts +++ b/packages/widget/src/markdown.ts @@ -16,6 +16,7 @@ function isSafeHref(href: string): boolean { // Browsers drop tabs and newlines anywhere in a URL and trim leading // control characters and spaces, so normalize the same way before // reading the scheme. URLs without a scheme are relative and safe. + // biome-ignore lint/suspicious/noControlCharactersInRegex: strip control characters from url const normalized = href.replace(/[\t\n\r]/g, '').replace(/^[\u0000-\u0020]+/, ''); const scheme = /^([^:/?#]+):/.exec(normalized); return !scheme || SAFE_SCHEMES.has(scheme[1].toLowerCase()); diff --git a/packages/widget/src/namespace.ts b/packages/widget/src/namespace.ts index f853204c..fb396d03 100644 --- a/packages/widget/src/namespace.ts +++ b/packages/widget/src/namespace.ts @@ -14,7 +14,8 @@ const DEFAULT = ''; const CLAIMS_KEY = Symbol.for('chatcops.storageClaims'); type Claims = Map boolean>>; const store = globalThis as typeof globalThis & { [CLAIMS_KEY]?: Claims }; -const claims: Claims = (store[CLAIMS_KEY] ??= new Map()); +store[CLAIMS_KEY] ??= new Map(); +const claims: Claims = store[CLAIMS_KEY]; export interface ClaimedNamespace { /** `undefined` means the default keys, which a single widget always gets. */ diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 8dcf3912..f5548e73 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -311,7 +311,9 @@ export class Widget { private emit(event: WidgetEventType, ...args: unknown[]): void { if (this.destroyed) return; - this.eventHandlers.get(event)?.forEach((h) => h(...args)); + this.eventHandlers.get(event)?.forEach((h) => { + h(...args); + }); } private isPreChatCompleted(): boolean { diff --git a/packages/widget/tests/dom/prechat-form.test.ts b/packages/widget/tests/dom/prechat-form.test.ts index c6fee63c..8de77ccc 100644 --- a/packages/widget/tests/dom/prechat-form.test.ts +++ b/packages/widget/tests/dom/prechat-form.test.ts @@ -109,7 +109,6 @@ describe('PreChatForm', () => { const onSubmit = vi.fn(); createForm(parent, [], onSubmit); - const btn = parent.querySelector('.cc-prechat-submit') as HTMLButtonElement; const form = parent.querySelector('form') as HTMLFormElement; form.dispatchEvent(new Event('submit', { cancelable: true })); diff --git a/packages/widget/tests/index.test.ts b/packages/widget/tests/index.test.ts index ae518c35..d524594a 100644 --- a/packages/widget/tests/index.test.ts +++ b/packages/widget/tests/index.test.ts @@ -210,7 +210,9 @@ describe('ChatCops browser export and auto-init', () => { let cleanups: Array<() => void> = []; afterEach(() => { - cleanups.forEach((c) => c()); + cleanups.forEach((c) => { + c(); + }); cleanups = []; ChatCops.destroy(); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8ab7b46..828968e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@axe-core/playwright': specifier: ^4.14.0 version: 4.14.0(playwright-core@1.64.1) + '@biomejs/biome': + specifier: ^2.5.15 + version: 2.5.15 '@changesets/cli': specifier: ^2.27.0 version: 2.30.0(@types/node@25.3.3) @@ -320,6 +323,59 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.5.15': + resolution: {integrity: sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.15': + resolution: {integrity: sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.15': + resolution: {integrity: sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.15': + resolution: {integrity: sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.5.15': + resolution: {integrity: sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.5.15': + resolution: {integrity: sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.5.15': + resolution: {integrity: sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.5.15': + resolution: {integrity: sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.15': + resolution: {integrity: sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@bruits/satteri-darwin-arm64@0.10.5': resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} cpu: [arm64] @@ -4262,6 +4318,41 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/biome@2.5.15': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.15 + '@biomejs/cli-darwin-x64': 2.5.15 + '@biomejs/cli-linux-arm64': 2.5.15 + '@biomejs/cli-linux-arm64-musl': 2.5.15 + '@biomejs/cli-linux-x64': 2.5.15 + '@biomejs/cli-linux-x64-musl': 2.5.15 + '@biomejs/cli-win32-arm64': 2.5.15 + '@biomejs/cli-win32-x64': 2.5.15 + + '@biomejs/cli-darwin-arm64@2.5.15': + optional: true + + '@biomejs/cli-darwin-x64@2.5.15': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.15': + optional: true + + '@biomejs/cli-linux-arm64@2.5.15': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.15': + optional: true + + '@biomejs/cli-linux-x64@2.5.15': + optional: true + + '@biomejs/cli-win32-arm64@2.5.15': + optional: true + + '@biomejs/cli-win32-x64@2.5.15': + optional: true + '@bruits/satteri-darwin-arm64@0.10.5': optional: true diff --git a/website/src/assets/chatcops-logo.svg b/website/src/assets/chatcops-logo.svg index ac6c2dd9..c1db2abe 100644 --- a/website/src/assets/chatcops-logo.svg +++ b/website/src/assets/chatcops-logo.svg @@ -1,4 +1,5 @@ + ChatCops diff --git a/website/src/components/react/CodeTabs.tsx b/website/src/components/react/CodeTabs.tsx index 866b9981..3579a597 100644 --- a/website/src/components/react/CodeTabs.tsx +++ b/website/src/components/react/CodeTabs.tsx @@ -100,6 +100,7 @@ export default function CodeTabs() {
{tabs.map((t) => ( + + + + diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 0e98703d..953973ae 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from '@playwright/test'; const port = Number(process.env.E2E_PORT ?? 4173); +const apiPort = Number(process.env.E2E_API_PORT ?? 4174); export default defineConfig({ testDir: './tests', @@ -13,10 +14,18 @@ export default defineConfig({ trace: 'retain-on-failure', }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], - webServer: { - command: 'node e2e/server.mjs', - cwd: '..', - url: `http://localhost:${port}/`, - reuseExistingServer: !process.env.CI, - }, + webServer: [ + { + command: 'node e2e/server.mjs page', + cwd: '..', + url: `http://localhost:${port}/`, + reuseExistingServer: !process.env.CI, + }, + { + command: 'node e2e/server.mjs api', + cwd: '..', + url: `http://localhost:${apiPort}/__test__/api-requests`, + reuseExistingServer: !process.env.CI, + }, + ], }); diff --git a/e2e/server.mjs b/e2e/server.mjs index 81b6f60d..aff79f76 100644 --- a/e2e/server.mjs +++ b/e2e/server.mjs @@ -8,13 +8,17 @@ import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, '..'); const port = Number(process.env.E2E_PORT ?? 4173); +const apiPort = Number(process.env.E2E_API_PORT ?? 4174); const files = { '/': ['fixtures/index.html', 'text/html'], '/index.html': ['fixtures/index.html', 'text/html'], '/two-widgets.html': ['fixtures/two-widgets.html', 'text/html'], + '/cross-origin.html': ['fixtures/cross-origin.html', 'text/html'], }; +const apiRequests = []; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function readJson(req) { @@ -33,13 +37,14 @@ async function readJson(req) { * - "cut off": some text, then the connection ends without [DONE] * - anything else: a normal streamed reply */ -async function chat(req, res) { +async function chat(req, res, cors = false) { const body = await readJson(req); const message = String(body.message ?? ''); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', + ...(cors ? { 'Access-Control-Allow-Origin': '*' } : {}), }); const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`); @@ -92,7 +97,41 @@ const server = createServer(async (req, res) => { const file = files[url.pathname]; if (file) { res.writeHead(200, { 'Content-Type': file[1] }); - return res.end(await readFile(join(here, file[0]))); + let content = await readFile(join(here, file[0]), 'utf8'); + content = content.replaceAll('__E2E_API_PORT__', String(apiPort)); + return res.end(content); + } + + res.writeHead(404); + res.end('Not found'); + } catch (error) { + res.writeHead(500); + res.end(String(error)); + } +}); + +const apiServer = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${apiPort}`); + try { + if (url.pathname === '/__test__/api-requests' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify(apiRequests)); + } + + if (url.pathname === '/api/chat' && req.method === 'OPTIONS') { + const headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }; + apiRequests.push({ method: 'OPTIONS', status: 204, headers }); + res.writeHead(204, headers); + return res.end(); + } + + if (url.pathname === '/api/chat' && req.method === 'POST') { + apiRequests.push({ method: 'POST' }); + return await chat(req, res, true); } res.writeHead(404); @@ -103,4 +142,21 @@ const server = createServer(async (req, res) => { } }); -server.listen(port, () => console.log(`e2e server on http://localhost:${port}`)); +server.on('error', (error) => console.error('e2e page server error:', error)); +apiServer.on('error', (error) => console.error('e2e API server error:', error)); + +const serverMode = process.argv[2] ?? 'both'; +if (serverMode === 'page' || serverMode === 'both') { + server.listen(port, () => console.log(`e2e page server on http://localhost:${port}`)); +} +if (serverMode === 'api' || serverMode === 'both') { + apiServer.listen(apiPort, () => console.log(`e2e API server on http://localhost:${apiPort}`)); +} + +function shutdown() { + if (server.listening) server.close(); + if (apiServer.listening) apiServer.close(); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); diff --git a/e2e/tests/cross-origin.spec.ts b/e2e/tests/cross-origin.spec.ts new file mode 100644 index 00000000..db85d687 --- /dev/null +++ b/e2e/tests/cross-origin.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; + +const apiUrl = `http://localhost:${process.env.E2E_API_PORT ?? 4174}/api/chat`; +const apiRequestsUrl = `http://localhost:${process.env.E2E_API_PORT ?? 4174}/__test__/api-requests`; +const input = '.cc-input'; +const send = '.cc-send-btn'; + +test('sends a browser CORS preflight before streaming a cross-origin reply', async ({ page, request }) => { + const beforeResponse = await request.get(apiRequestsUrl); + const beforeRequests = (await beforeResponse.json()) as ApiRequest[]; + + await page.goto('/cross-origin.html'); + await page.locator('.cc-fab').click(); + await expect(page.locator('.cc-panel')).toHaveClass(/cc-visible/); + await page.locator(input).fill('Cross-origin hello'); + + const postResponse = page.waitForResponse( + (response) => response.url() === apiUrl && response.request().method() === 'POST', + ); + await page.locator(send).click(); + + const post = await postResponse; + expect(post.status()).toBe(200); + expect(post.headers()['access-control-allow-origin']).toBe('*'); + await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the mock server.'); + + const afterResponse = await request.get(apiRequestsUrl); + const requests = (await afterResponse.json()) as ApiRequest[]; + const observedRequests = requests.slice(beforeRequests.length); + expect(observedRequests.map(({ method }) => method)).toEqual(['OPTIONS', 'POST']); + expect(observedRequests[0]).toMatchObject({ + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); +}); + +interface ApiRequest { + method: string; + status?: number; + headers?: Record; +} From 74f4c6332222634850418448dc08f0177305fb7a Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:05:19 +0530 Subject: [PATCH 60/80] feat(store-sqlite): add rowHook and extraColumns (#299) --- .changeset/sqlite-row-hook.md | 5 + packages/store-sqlite/README.md | 19 ++++ packages/store-sqlite/src/index.ts | 98 ++++++++++++++------ packages/store-sqlite/src/row-hook.ts | 64 +++++++++++++ packages/store-sqlite/tests/row-hook.test.ts | 86 +++++++++++++++++ 5 files changed, 243 insertions(+), 29 deletions(-) create mode 100644 .changeset/sqlite-row-hook.md create mode 100644 packages/store-sqlite/src/row-hook.ts create mode 100644 packages/store-sqlite/tests/row-hook.test.ts diff --git a/.changeset/sqlite-row-hook.md b/.changeset/sqlite-row-hook.md new file mode 100644 index 00000000..4938d719 --- /dev/null +++ b/.changeset/sqlite-row-hook.md @@ -0,0 +1,5 @@ +--- +"@chatcops/store-sqlite": minor +--- + +`extraColumns` and `rowHook` stamp columns you've added (a tenant or site id) on every new conversation and message row, as in `@chatcops/store-postgres`. The hook runs on insert only, undeclared keys throw, and values are always bound as parameters. diff --git a/packages/store-sqlite/README.md b/packages/store-sqlite/README.md index 9c8851a2..2cb9dfc0 100644 --- a/packages/store-sqlite/README.md +++ b/packages/store-sqlite/README.md @@ -24,3 +24,22 @@ app.use('/api/chat', chatcopsMiddleware({ Use `path: ':memory:'` for tests, or pass an already-open `node:sqlite` `DatabaseSync` (or structurally compatible better-sqlite3 database) with `database`. SQLite is intended here for one server process and a local file. Back up the database file using your normal deployment process. Call `await storage.close?.()` during graceful shutdown. + +## Extra columns + +To stamp columns of your own (a tenant or site id) on every new row, add them with your own `ALTER TABLE` and pass `extraColumns` with a `rowHook`: + +```ts +database.exec('ALTER TABLE chatcops_conversations ADD COLUMN workspace_id TEXT'); +database.exec('ALTER TABLE chatcops_messages ADD COLUMN workspace_id TEXT'); + +const storage = sqliteStorage({ + database, + extraColumns: ['workspace_id'], + rowHook: ({ metadata }) => ({ workspace_id: metadata?.workspaceId }), +}); +``` + +The hook runs when a row is created, never on updates. It gets the table, the conversation id, the metadata passed with the write, and the message for message rows. Returning a key that isn't in `extraColumns` throws and nothing from that write is stored. Values are bound as parameters, never put into the SQL, and must be strings, numbers, booleans (stored as 1 or 0) or null. + +The `{ workspace_id: 'uuid' }` form from `@chatcops/store-postgres` works too, so one config fits both stores. SQLite ignores the type. diff --git a/packages/store-sqlite/src/index.ts b/packages/store-sqlite/src/index.ts index 40d1165d..07d21bfe 100644 --- a/packages/store-sqlite/src/index.ts +++ b/packages/store-sqlite/src/index.ts @@ -13,9 +13,29 @@ import { type ListQuery, } from '@chatcops/core'; import { all, get, openDatabase, run, transaction, type SqliteDatabase } from './database.js'; +import { checkRowHook, type ExtraColumns, type RowHook, type RowHookContext, type RowHookFunction } from './row-hook.js'; import { initializeSchema } from './schema.js'; -export interface SqliteStorageOptions { path?: string; database?: SqliteDatabase } +export type { ExtraColumns, RowHookContext, RowHookFunction } from './row-hook.js'; + +export interface SqliteStorageOptions { + /** A database file, or `:memory:`. */ + path?: string; + /** An open `node:sqlite` `DatabaseSync`, or a compatible better-sqlite3 database. */ + database?: SqliteDatabase; + /** + * Columns you've added to the conversations and messages tables (with your + * own `ALTER TABLE`), filled in by `rowHook` when a row is created. + */ + extraColumns?: ExtraColumns; + /** + * Values for `extraColumns` on each new conversation and message row. Runs + * on insert only, never on update. Returning a key that isn't declared in + * `extraColumns` throws, and nothing from that write is stored. Values are + * bound as parameters, never put into the SQL. + */ + rowHook?: RowHookFunction; +} interface ConversationRow { id: string; metadata: string | null; version: number; created_at: number; updated_at: number } interface MessageRow { message: string } @@ -26,7 +46,38 @@ const changes = (result: RunResult): number => Number(result.changes ?? 0); const now = () => Date.now(); class SqliteConversations implements ConversationStore { - constructor(private readonly db: SqliteDatabase, private readonly clock: () => number) {} + private readonly conversationInsert: string; + private readonly messageInsert: string; + + constructor( + private readonly db: SqliteDatabase, + private readonly clock: () => number, + private readonly hook?: RowHook + ) { + const extra = hook?.columns.map((name) => `, ${name}`).join('') ?? ''; + const marks = hook?.columns.map(() => ', ?').join('') ?? ''; + this.conversationInsert = `INSERT INTO chatcops_conversations (id, metadata, version, created_at, updated_at${extra}) VALUES (?, ?, ?, ?, ?${marks})`; + this.messageInsert = `INSERT INTO chatcops_messages (conversation_id, seq, id, message${extra}) VALUES (?, ?, ?, ?${marks})`; + } + + /** The hook's values for one new row, or nothing without a hook. */ + private extra(context: RowHookContext): unknown[] { + return this.hook ? this.hook.values(context) : []; + } + + private insertMessages(id: string, after: number, messages: ChatMessage[], metadata: Record | undefined): void { + for (const [index, message] of messages.entries()) { + run( + this.db, + this.messageInsert, + id, + after + index + 1, + message.id, + JSON.stringify(message), + ...this.extra({ table: 'messages', conversationId: id, metadata, message }) + ); + } + } get(id: string): Promise { const row = get(this.db, 'SELECT * FROM chatcops_conversations WHERE id = ?', id); @@ -50,10 +101,10 @@ class SqliteConversations implements ConversationStore { transaction(this.db, () => { const current = get(this.db, 'SELECT version FROM chatcops_conversations WHERE id = ?', conversation.id); const version = (current?.version ?? 0) + 1; + // The hook's columns are only in the INSERT half, so an update never changes them. run( this.db, - `INSERT INTO chatcops_conversations (id, metadata, version, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) + `${this.conversationInsert} ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, version = excluded.version, @@ -63,24 +114,19 @@ class SqliteConversations implements ConversationStore { json(conversation.metadata), version, conversation.createdAt, - this.clock() + this.clock(), + ...this.extra({ table: 'conversations', conversationId: conversation.id, metadata: conversation.metadata }) ); run(this.db, 'DELETE FROM chatcops_messages WHERE conversation_id = ?', conversation.id); - for (const [index, message] of conversation.messages.entries()) { - run( - this.db, - 'INSERT INTO chatcops_messages (conversation_id, seq, id, message) VALUES (?, ?, ?, ?)', - conversation.id, - index + 1, - message.id, - JSON.stringify(message) - ); - } + this.insertMessages(conversation.id, 0, conversation.messages, conversation.metadata); }); return Promise.resolve(); } - delete(id: string): Promise { run(this.db, 'DELETE FROM chatcops_conversations WHERE id = ?', id); return Promise.resolve(); } + delete(id: string): Promise { + run(this.db, 'DELETE FROM chatcops_conversations WHERE id = ?', id); + return Promise.resolve(); + } async append(id: string, messages: ChatMessage[], options: AppendOptions = {}): Promise<{ version: number }> { const version = transaction(this.db, () => { @@ -94,11 +140,13 @@ class SqliteConversations implements ConversationStore { if (!current) { run( this.db, - 'INSERT INTO chatcops_conversations (id, metadata, version, created_at, updated_at) VALUES (?, ?, 1, ?, ?)', + this.conversationInsert, id, json(metadata), + 1, time, - time + time, + ...this.extra({ table: 'conversations', conversationId: id, metadata: options.metadata }) ); } else { run( @@ -110,16 +158,7 @@ class SqliteConversations implements ConversationStore { ); } const last = get<{ seq: number }>(this.db, 'SELECT COALESCE(MAX(seq), 0) AS seq FROM chatcops_messages WHERE conversation_id = ?', id)?.seq ?? 0; - for (const [index, message] of messages.entries()) { - run( - this.db, - 'INSERT INTO chatcops_messages (conversation_id, seq, id, message) VALUES (?, ?, ?, ?)', - id, - last + index + 1, - message.id, - JSON.stringify(message) - ); - } + this.insertMessages(id, last, messages, options.metadata); return actual + 1; }); return { version }; @@ -321,11 +360,12 @@ class SqliteEvents implements EventSink { export function sqliteStorage(options: SqliteStorageOptions): ChatCopsStorage { if ((!options.path && !options.database) || (options.path && options.database)) throw new Error('sqliteStorage requires exactly one of path or database'); + const hook = checkRowHook(options.extraColumns, options.rowHook); const database = options.database ?? openDatabase(options.path ?? ''); initializeSchema(database); const clock = now; return { - conversations: new SqliteConversations(database, clock), + conversations: new SqliteConversations(database, clock, hook), counters: new SqliteCounters(database, clock), kv: new SqliteKv(database, clock), events: new SqliteEvents(database), diff --git a/packages/store-sqlite/src/row-hook.ts b/packages/store-sqlite/src/row-hook.ts new file mode 100644 index 00000000..93c8fad3 --- /dev/null +++ b/packages/store-sqlite/src/row-hook.ts @@ -0,0 +1,64 @@ +import type { ChatMessage } from '@chatcops/core'; + +/** What the hook gets for each new row. */ +export interface RowHookContext { + table: 'conversations' | 'messages'; + conversationId: string; + /** The metadata passed with this write (`append` options or the saved conversation). */ + metadata?: Record; + /** The message being inserted, for the messages table. */ + message?: ChatMessage; +} + +export type RowHookFunction = (context: RowHookContext) => Record; + +/** + * Columns you've added to the conversations and messages tables, by name. + * The `{ name: type }` form from `@chatcops/store-postgres` works too, so one + * config fits both stores; SQLite stores each value with its own type, so the + * type is ignored here. + */ +export type ExtraColumns = string[] | Record; + +const IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/; +const RESERVED = new Set(['id', 'metadata', 'version', 'created_at', 'updated_at', 'conversation_id', 'seq', 'message']); + +/** A checked hook: the columns it may set, and a function that refuses any others. */ +export interface RowHook { + columns: string[]; + /** The values for `columns`, in order, ready to bind. */ + values(context: RowHookContext): unknown[]; +} + +export function checkRowHook(extraColumns: ExtraColumns | undefined, rowHook: RowHookFunction | undefined): RowHook | undefined { + if (!rowHook && !extraColumns) return undefined; + if (!rowHook || !extraColumns) throw new Error('sqliteStorage needs both extraColumns and rowHook, or neither.'); + + const columns = Array.isArray(extraColumns) ? extraColumns : Object.keys(extraColumns); + for (const name of columns) { + if (!IDENTIFIER.test(name)) throw new Error(`extraColumns: "${name}" isn't a plain lowercase column name.`); + if (RESERVED.has(name)) throw new Error(`extraColumns: "${name}" is one of the store's own columns.`); + } + const allowed = new Set(columns); + + return { + columns, + values(context) { + const row = rowHook(context) ?? {}; + for (const key of Object.keys(row)) { + if (!allowed.has(key)) { + throw new Error(`rowHook returned "${key}", which isn't in extraColumns. Declare it there first.`); + } + } + return columns.map((name) => bindable(name, row[name])); + }, + }; +} + +/** node:sqlite binds strings, numbers, bigints, bytes and null. Booleans are stored as the integers 1 and 0. */ +function bindable(name: string, value: unknown): unknown { + if (value === undefined || value === null) return null; + if (typeof value === 'boolean') return value ? 1n : 0n; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint' || value instanceof Uint8Array) return value; + throw new Error(`rowHook returned an unsupported value (${typeof value}) for "${name}". Use a string, number, boolean or null.`); +} diff --git a/packages/store-sqlite/tests/row-hook.test.ts b/packages/store-sqlite/tests/row-hook.test.ts new file mode 100644 index 00000000..2c7907a7 --- /dev/null +++ b/packages/store-sqlite/tests/row-hook.test.ts @@ -0,0 +1,86 @@ +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sqliteStorage, type RowHookFunction } from '../src/index.js'; + +const workspace = '6f1c2a5e-8f2b-4d1a-9c3e-2b7a1d4e5f60'; +const databases: DatabaseSync[] = []; + +afterEach(() => { + for (const database of databases.splice(0)) database.close(); +}); + +/** A database with the tenant columns the cloud's own migration adds. */ +function setup(rowHook: RowHookFunction = ({ metadata }) => ({ workspace_id: metadata?.workspaceId, site_id: metadata?.siteId })) { + const database = new DatabaseSync(':memory:'); + databases.push(database); + sqliteStorage({ database }); + for (const table of ['chatcops_conversations', 'chatcops_messages']) { + database.exec(`ALTER TABLE ${table} ADD COLUMN workspace_id TEXT`); + database.exec(`ALTER TABLE ${table} ADD COLUMN site_id TEXT`); + } + const hook = vi.fn(rowHook); + const store = sqliteStorage({ database, extraColumns: { workspace_id: 'uuid', site_id: 'text' }, rowHook: hook }).conversations; + const rows = (sql: string) => database.prepare(sql).all().map((row) => ({ ...row })); + return { store, hook, rows }; +} + +describe('rowHook and extraColumns', () => { + it('stamps new conversation and message rows from the metadata', async () => { + const { store, hook, rows } = setup(); + await store.append?.('c1', [{ id: 'u1', role: 'user', content: 'Hi', timestamp: 1 }], { + metadata: { workspaceId: workspace, siteId: 'site-1' }, + }); + + expect(rows(`SELECT workspace_id, site_id FROM chatcops_conversations WHERE id = 'c1'`)).toEqual([{ workspace_id: workspace, site_id: 'site-1' }]); + expect(rows(`SELECT workspace_id, site_id FROM chatcops_messages WHERE conversation_id = 'c1'`)).toEqual([{ workspace_id: workspace, site_id: 'site-1' }]); + expect(hook).toHaveBeenCalledWith(expect.objectContaining({ table: 'messages', conversationId: 'c1', message: expect.objectContaining({ id: 'u1' }) })); + }); + + it('only stamps on insert: a later append or save leaves the conversation row alone', async () => { + const { store, rows } = setup(); + await store.append?.('c2', [{ id: 'u1', role: 'user', content: 'Hi', timestamp: 1 }], { metadata: { workspaceId: workspace, siteId: 'first' } }); + await store.append?.('c2', [{ id: 'u2', role: 'user', content: 'Again', timestamp: 2 }], { metadata: { siteId: 'second' } }); + expect(rows(`SELECT site_id FROM chatcops_conversations WHERE id = 'c2'`)).toEqual([{ site_id: 'first' }]); + expect(rows(`SELECT site_id FROM chatcops_messages WHERE conversation_id = 'c2' ORDER BY seq`)).toEqual([{ site_id: 'first' }, { site_id: 'second' }]); + + const conversation = await store.get('c2'); + if (!conversation) throw new Error('c2 was not stored'); + await store.save({ ...conversation, metadata: { siteId: 'third' } }); + expect(rows(`SELECT site_id FROM chatcops_conversations WHERE id = 'c2'`)).toEqual([{ site_id: 'first' }]); + }); + + it('binds values as parameters, never as SQL, and stores booleans as 1 or 0', async () => { + const { store, rows } = setup(({ metadata }) => ({ site_id: metadata?.siteId, workspace_id: true })); + const sneaky = "x'); DROP TABLE chatcops_messages; --"; + await store.save({ id: 'c3', messages: [{ id: 'u1', role: 'user', content: 'Hi', timestamp: 1 }], metadata: { siteId: sneaky }, createdAt: 1, updatedAt: 1 }); + expect(rows(`SELECT site_id, workspace_id FROM chatcops_messages WHERE conversation_id = 'c3'`)).toEqual([{ site_id: sneaky, workspace_id: '1' }]); + }); + + it('refuses a key that is not declared, and stores nothing from that write', async () => { + const { store } = setup(() => ({ tenant: 'x' })); + await expect(store.append?.('c4', [{ id: 'u1', role: 'user', content: 'Hi', timestamp: 1 }])).rejects.toThrow( + 'rowHook returned "tenant", which isn\'t in extraColumns' + ); + expect(await store.get('c4')).toBeNull(); + }); + + it('refuses a value SQLite cannot store', async () => { + const { store } = setup(() => ({ site_id: { nested: true } })); + await expect(store.append?.('c5', [{ id: 'u1', role: 'user', content: 'Hi', timestamp: 1 }])).rejects.toThrow( + 'rowHook returned an unsupported value (object) for "site_id"' + ); + }); +}); + +describe('extraColumns checks', () => { + it('needs both options or neither', () => { + expect(() => sqliteStorage({ path: ':memory:', extraColumns: ['a'] })).toThrow('both extraColumns and rowHook'); + expect(() => sqliteStorage({ path: ':memory:', rowHook: () => ({}) })).toThrow('both extraColumns and rowHook'); + }); + + it("only takes plain column names, and not the store's own columns", () => { + const rowHook = () => ({}); + expect(() => sqliteStorage({ path: ':memory:', extraColumns: ['bad name'], rowHook })).toThrow('plain lowercase column name'); + expect(() => sqliteStorage({ path: ':memory:', extraColumns: ['message'], rowHook })).toThrow("store's own columns"); + }); +}); From 746b2cfbb09bab8a25a862275a4195a07e160c0f Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:06:19 +0530 Subject: [PATCH 61/80] docs(website): drop the stale timer note in the chat route (#300) --- website/src/pages/api/chat.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/src/pages/api/chat.ts b/website/src/pages/api/chat.ts index c12949f3..a4972d77 100644 --- a/website/src/pages/api/chat.ts +++ b/website/src/pages/api/chat.ts @@ -49,8 +49,7 @@ const faq = new FAQKnowledgeSource([ ]); // Built on the first request rather than at import: the API key is a Worker -// secret that only exists inside a request, and the handler starts a -// timer, which Workers don't allow at global scope. +// secret that only exists inside a request. let chat: ReturnType | undefined; function getChat() { chat ??= chatcopsCloudflareHandler({ From ad1d9db3da68ab4be8406ab6d7963b477d5ee096 Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:15:04 +0530 Subject: [PATCH 62/80] feat(server): add POST /session and session tokens on chat requests (#301) --- .changeset/session-endpoint.md | 6 + packages/core/src/protocol/errors.ts | 1 + packages/core/src/protocol/v1.ts | 1 + packages/core/tests/protocol/errors.test.ts | 1 + packages/core/tests/protocol/legacy.test.ts | 1 + packages/server/src/adapters/express.ts | 18 +- packages/server/src/config.ts | 18 ++ packages/server/src/handler.ts | 19 +- packages/server/src/http.ts | 23 +- packages/server/src/security/sessions.ts | 141 ++++++++++ packages/server/tests/sessions.test.ts | 275 ++++++++++++++++++++ website/src/content/docs/server/setup.mdx | 33 +++ 12 files changed, 532 insertions(+), 5 deletions(-) create mode 100644 .changeset/session-endpoint.md create mode 100644 packages/server/src/security/sessions.ts create mode 100644 packages/server/tests/sessions.test.ts diff --git a/.changeset/session-endpoint.md b/.changeset/session-endpoint.md new file mode 100644 index 00000000..05378410 --- /dev/null +++ b/.changeset/session-endpoint.md @@ -0,0 +1,6 @@ +--- +"@chatcops/server": minor +"@chatcops/core": minor +--- + +Session tokens. With `sessionSecrets` set, `POST /session` gives a short-lived token bound to one conversation and the page's origin, and chat requests need it as `Authorization: Bearer ` (401 `session_required`, `token_expired` or `conversation_mismatch` otherwise). A conversation is resumed only with a token that was issued for it, so a guessed id gets a new conversation. `requireSession: false` lets requests without a token through while older widgets are around. Adds the `conversation_mismatch` error code to protocol v1. diff --git a/packages/core/src/protocol/errors.ts b/packages/core/src/protocol/errors.ts index e24c1202..190dd37f 100644 --- a/packages/core/src/protocol/errors.ts +++ b/packages/core/src/protocol/errors.ts @@ -6,6 +6,7 @@ const STATUS_BY_CODE: Record = { content_filtered: 400, session_required: 401, token_expired: 401, + conversation_mismatch: 401, origin_not_allowed: 403, conversation_busy: 409, payload_too_large: 413, diff --git a/packages/core/src/protocol/v1.ts b/packages/core/src/protocol/v1.ts index 1698bec5..630bdcd6 100644 --- a/packages/core/src/protocol/v1.ts +++ b/packages/core/src/protocol/v1.ts @@ -20,6 +20,7 @@ export type WireErrorCode = | 'invalid_request' | 'session_required' | 'token_expired' + | 'conversation_mismatch' | 'origin_not_allowed' | 'conversation_busy' | 'payload_too_large' diff --git a/packages/core/tests/protocol/errors.test.ts b/packages/core/tests/protocol/errors.test.ts index db4163ed..aeb40e11 100644 --- a/packages/core/tests/protocol/errors.test.ts +++ b/packages/core/tests/protocol/errors.test.ts @@ -14,6 +14,7 @@ describe('statusForError', () => { content_filtered: 400, session_required: 401, token_expired: 401, + conversation_mismatch: 401, origin_not_allowed: 403, conversation_busy: 409, payload_too_large: 413, diff --git a/packages/core/tests/protocol/legacy.test.ts b/packages/core/tests/protocol/legacy.test.ts index d0831af6..2cafad1b 100644 --- a/packages/core/tests/protocol/legacy.test.ts +++ b/packages/core/tests/protocol/legacy.test.ts @@ -80,6 +80,7 @@ describe('toLegacyChunk', () => { it.each([ 'session_required', 'token_expired', + 'conversation_mismatch', 'origin_not_allowed', 'conversation_busy', 'payload_too_large', diff --git a/packages/server/src/adapters/express.ts b/packages/server/src/adapters/express.ts index 85cfd884..adbefce3 100644 --- a/packages/server/src/adapters/express.ts +++ b/packages/server/src/adapters/express.ts @@ -1,7 +1,8 @@ +import { errorBody } from '@chatcops/core'; import type { Request, Response, NextFunction } from 'express'; import { expressClientIp } from '../client-ip.js'; import { createChatHandler } from '../handler.js'; -import { PROTOCOL_HEADER, chatReply } from '../http.js'; +import { PROTOCOL_HEADER, chatReply, isSessionPath } from '../http.js'; import { originPolicy } from '../security/origin-policy.js'; import type { ChatCopsServerConfig } from '../config.js'; @@ -49,6 +50,18 @@ export function chatcopsMiddleware(config: ChatCopsServerConfig) { return; } + // `app.use('/api/chat', chatcopsMiddleware(...))` sends /api/chat/session here too. + if (isSessionPath(req.path ?? '')) { + setHeaders(origins.headers(origin)); + if (!chat.startSession) { + res.status(404).json(errorBody('invalid_request', { message: 'Session tokens are off. Set `sessionSecrets` to turn them on.' })); + return; + } + const reply = await chat.startSession(req.body, origin); + res.status(reply.status).json(reply.body); + return; + } + const clientIp = expressClientIp(req); // Stop the reply when the visitor goes away before it's done. @@ -58,9 +71,12 @@ export function chatcopsMiddleware(config: ChatCopsServerConfig) { }); const protocolHeader = req.headers?.[PROTOCOL_HEADER]; + const authorization = req.headers?.authorization; const reply = await chatReply(chat, req.body, clientIp, { signal: abort.signal, protocolHeader: typeof protocolHeader === 'string' ? protocolHeader : null, + authorization: typeof authorization === 'string' ? authorization : null, + origin, }); if (reply.kind === 'json') { setHeaders({ ...origins.headers(origin), ...reply.headers }); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 230ac6d7..b81e6647 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -83,6 +83,24 @@ export interface ChatCopsServerConfig { * Express uses `req.ip`, which follows its `trust proxy` setting. */ clientIp?: ClientIpResolver; + /** + * Turns on session tokens. `POST /session` gives the widget a + * token bound to its origin and one conversation, and chat requests send + * it as `Authorization: Bearer `, so a guessed conversation id is + * useless. Sign with the first secret and verify with all of them, so a + * new secret goes first and the old one stays until its tokens expire. + * Each needs at least 32 bytes (`openssl rand -base64 32`). Keep them + * server side, never in a client bundle. + */ + sessionSecrets?: string[]; + /** How long a session token lasts, in seconds. Defaults to 900. */ + sessionTtlSec?: number; + /** + * Refuse chat requests without a session token (401 `session_required`). + * Defaults to true when `sessionSecrets` is set. Set false while widgets + * older than 0.7, which don't send tokens, are still out there. + */ + requireSession?: boolean; /** * Adds the reply's token usage to the `finish` part of protocol v1 * streams. Off by default, since visitors can read the stream. diff --git a/packages/server/src/handler.ts b/packages/server/src/handler.ts index 1d492616..f574e85e 100644 --- a/packages/server/src/handler.ts +++ b/packages/server/src/handler.ts @@ -28,6 +28,7 @@ import { } from '@chatcops/core'; import type { ChatCopsServerConfig, ValidatedChatRequest } from './config.js'; import { chatRequestSchema } from './config.js'; +import { createSessions } from './security/sessions.js'; import { VISITOR_CONTEXT_INSTRUCTION, buildVisitorContext, isLocaleTag, withVisitorContext } from './visitor-context.js'; import { WebhookDispatcher } from './webhook.js'; @@ -80,7 +81,7 @@ export function createChatHandler(config: ChatCopsServerConfig) { // rather than as a provider error on the first message. validateTools(toolDefs); const tools = toolRegistry(config.tools); - + const sessions = createSessions(config); const requests: Record = { v1: 0, legacy: 0, 'ai-sdk': 0 }; @@ -110,6 +111,11 @@ export function createChatHandler(config: ChatCopsServerConfig) { } const req: ValidatedChatRequest = parsed.data; + if (sessions) { + const session = await sessions.check(options.authorization, options.origin ?? null, req.conversationId); + if (!session.ok) return refuse(session.code, { message: session.message }, { error: 'provider_error' }); + } + if (clientIp) { const limit = await rateLimiter.check(clientIp); if (!limit.allowed) { @@ -146,7 +152,10 @@ export function createChatHandler(config: ChatCopsServerConfig) { ): AsyncGenerator { const pendingWebhooks = new Set>(); try { - yield* reply(provider, req, signal, pendingWebhooks, clientIp); + for await (const part of reply(provider, req, signal, pendingWebhooks, clientIp)) { + // Tells the widget to get a session before its next request. + yield part.type === 'start' && sessions ? { ...part, capabilities: { ...part.capabilities, session: true } } : part; + } } finally { await release(); } @@ -377,6 +386,8 @@ export function createChatHandler(config: ChatCopsServerConfig) { startChat, purge, /** How many requests asked for each stream format, to tell when 0.5 widgets are gone. */ + /** Answers `POST /session`, or `undefined` when session tokens are off. */ + startSession: sessions ? (body: unknown, origin: string | null) => sessions.mint(body, origin) : undefined, protocolStats: () => ({ ...requests }), getAnalytics: () => analytics?.getStats() ?? null, cleanup, @@ -390,6 +401,10 @@ export interface StartChatOptions { signal?: AbortSignal; /** Which stream the caller writes, for `protocolStats()`. Defaults to `'v1'`. */ format?: ChatFormat; + /** The request's `Authorization` header, for session tokens. */ + authorization?: string | null; + /** The request's `Origin` header, which a session token is bound to. */ + origin?: string | null; } /** A request the handler won't answer, or the reply's parts. */ diff --git a/packages/server/src/http.ts b/packages/server/src/http.ts index e510f1ac..bbcb6513 100644 --- a/packages/server/src/http.ts +++ b/packages/server/src/http.ts @@ -45,6 +45,10 @@ export interface ChatReplyOptions { pingIntervalMs?: number; /** Answer in the AI SDK's UI message stream instead of choosing by the request. */ aiSdk?: boolean; + /** The `Authorization` request header, for session tokens. */ + authorization?: string | null; + /** The `Origin` request header. */ + origin?: string | null; } export type ChatReply = @@ -66,7 +70,7 @@ export async function chatReply( let start: ChatStart; try { - start = await handler.startChat(body, clientIp, { signal, format }); + start = await handler.startChat(body, clientIp, { signal, format, authorization: options.authorization, origin: options.origin }); } catch (err) { if (!signal.aborted) console.error('[chatcops] Unhandled error while handling chat:', err); if (typed) return { kind: 'json', status: 500, headers: EXPOSE_HEADERS, body: errorBody('internal_error') }; @@ -229,9 +233,14 @@ export interface FetchChatOptions { aiSdk?: boolean; } +/** Whether a request is for the session endpoint: the chat URL plus `/session`. */ +export function isSessionPath(pathname: string): boolean { + return /\/session\/?$/.test(pathname); +} + /** The whole request for Fetch API runtimes (Vercel, Cloudflare, Next.js, Bun, Deno). */ export async function fetchChatResponse( - handler: Pick, + handler: Pick, origins: OriginPolicy, request: Request, options: FetchChatOptions @@ -248,6 +257,14 @@ export async function fetchChatResponse( if (request.method !== 'POST') return json(405, { error: 'Method not allowed' }); if (!origins.allows(origin)) return json(403, { error: 'origin_not_allowed' }); + if (isSessionPath(new URL(request.url).pathname)) { + if (!handler.startSession) return json(404, errorBody('invalid_request', { message: 'Session tokens are off. Set `sessionSecrets` to turn them on.' })); + const read = await readJsonBody(request, 16 * 1024); + if (!read.ok) return json(400, errorBody('invalid_request', { message: 'Invalid session request' })); + const reply = await handler.startSession(read.body, origin); + return json(reply.status, reply.body); + } + const clientIp = options.clientIp(request); const read = await readJsonBody(request); if (!read.ok) { @@ -266,6 +283,8 @@ export async function fetchChatResponse( protocolHeader, pingIntervalMs: options.pingIntervalMs, aiSdk: options.aiSdk, + authorization: request.headers.get('authorization'), + origin, }); if (reply.kind === 'json') return json(reply.status, reply.body, reply.headers); return sseResponse(reply.frames, { ...reply.headers, ...cors }, abort, options.waitUntil); diff --git a/packages/server/src/security/sessions.ts b/packages/server/src/security/sessions.ts new file mode 100644 index 00000000..e1b164bc --- /dev/null +++ b/packages/server/src/security/sessions.ts @@ -0,0 +1,141 @@ +import { errorBody, type WireErrorBody, type WireErrorCode } from '@chatcops/core'; +import { importSessionKey, signSession, verifySession, type SessionClaims } from './session-token.js'; + +/** + * Session tokens for the chat endpoint. `POST /session` gives the widget a + * short-lived token bound to its origin and one conversation id, and chat + * requests send it as `Authorization: Bearer `. A visitor who guesses + * another conversation's id can't get a token for it, so the id alone no + * longer reads or writes someone else's conversation. + */ + +export const DEFAULT_SESSION_TTL_SEC = 900; +/** An expired token still resumes its conversation for this long after it expires. */ +export const SESSION_RESUME_GRACE_SEC = 30 * 24 * 60 * 60; +const MIN_SECRET_BYTES = 32; +const MAX_ID_LENGTH = 128; + +export interface SessionConfig { + sessionSecrets?: string[]; + sessionTtlSec?: number; + requireSession?: boolean; +} + +export type SessionCheck = + | { ok: true; claims?: SessionClaims } + | { ok: false; code: WireErrorCode; message: string }; + +export interface SessionReply { + status: number; + body: { token: string; expiresAt: number; conversationId: string } | WireErrorBody; +} + +export interface Sessions { + /** Whether chat requests without a token are refused. */ + required: boolean; + /** Answers `POST /session`. */ + mint(body: unknown, origin: string | null): Promise; + /** Checks the `Authorization` header of a chat request for this conversation. */ + check(authorization: string | null | undefined, origin: string | null, conversationId: string): Promise; +} + +/** `undefined` when `sessionSecrets` isn't set. Throws at startup on a bad config. */ +export function createSessions(config: SessionConfig, now: () => number = Date.now): Sessions | undefined { + const secrets = config.sessionSecrets; + if (secrets === undefined) { + if (config.requireSession) throw new Error('`requireSession` needs `sessionSecrets` to sign tokens with.'); + return undefined; + } + if (!Array.isArray(secrets) || secrets.length === 0) throw new Error('`sessionSecrets` needs at least one secret.'); + secrets.forEach((secret, i) => { + const bytes = typeof secret === 'string' ? new TextEncoder().encode(secret).byteLength : 0; + if (bytes < MIN_SECRET_BYTES) { + throw new Error(`sessionSecrets[${i}] is ${bytes} bytes; it needs at least ${MIN_SECRET_BYTES}. Generate one with \`openssl rand -base64 32\`.`); + } + }); + const ttlSec = config.sessionTtlSec ?? DEFAULT_SESSION_TTL_SEC; + if (!(Number.isInteger(ttlSec) && ttlSec > 0)) throw new Error('`sessionTtlSec` must be a whole number of seconds above 0.'); + + // Imported on first use: Workers don't allow crypto.subtle at global scope. + let keys: Promise | undefined; + const getKeys = () => (keys ??= Promise.all(secrets.map((secret) => importSessionKey(secret)))); + const nowSec = () => Math.floor(now() / 1000); + const required = config.requireSession ?? true; + + return { + required, + + async mint(body, origin) { + const request = readMintRequest(body); + if (!request) return { status: 400, body: errorBody('invalid_request', { message: 'Invalid session request' }) }; + const org = origin ?? ''; + const time = nowSec(); + + // Resuming a conversation needs a token that was issued for it, from + // this origin. Expired is fine within the grace window. Anything else + // gets a new conversation, so a guessed id is useless. + let resumed: SessionClaims | undefined; + if (request.conversationId && request.previousToken) { + const result = await verifySession(request.previousToken, await getKeys(), { + origin: org, + conversationId: request.conversationId, + now: time, + clockSkewSec: SESSION_RESUME_GRACE_SEC, + }); + if (result.ok) resumed = result.claims; + } + + const claims: SessionClaims = { + v: 1, + sid: resumed?.sid ?? crypto.randomUUID(), + cid: resumed?.cid ?? crypto.randomUUID(), + org, + iat: time, + exp: time + ttlSec, + }; + const token = await signSession(claims, await getKeys()); + return { status: 200, body: { token, expiresAt: claims.exp * 1000, conversationId: claims.cid } }; + }, + + async check(authorization, origin, conversationId): Promise { + const token = bearer(authorization); + if (!token) { + return required + ? { ok: false, code: 'session_required', message: 'This chat needs a session token. Get one from POST /session.' } + : { ok: true }; + } + const result = await verifySession(token, await getKeys(), { origin: origin ?? '', conversationId, now: nowSec() }); + if (result.ok) return { ok: true, claims: result.claims }; + switch (result.code) { + case 'expired': + return { ok: false, code: 'token_expired', message: 'The session token has expired. Get a new one from POST /session.' }; + case 'conversation_mismatch': + return { ok: false, code: 'conversation_mismatch', message: 'The session token is for a different conversation.' }; + default: + return { ok: false, code: 'session_required', message: 'The session token is invalid. Get a new one from POST /session.' }; + } + }, + }; +} + +function bearer(header: string | null | undefined): string | undefined { + if (typeof header !== 'string') return undefined; + const match = /^Bearer\s+(\S+)\s*$/i.exec(header); + return match?.[1]; +} + +function readMintRequest(body: unknown): { conversationId?: string; previousToken?: string } | undefined { + if (body === undefined || body === null) return {}; + if (typeof body !== 'object' || Array.isArray(body)) return undefined; + const { conversationId, previousToken } = body as Record; + const request: { conversationId?: string; previousToken?: string } = {}; + if (conversationId !== undefined) { + if (typeof conversationId !== 'string' || conversationId.length === 0 || conversationId.length > MAX_ID_LENGTH) return undefined; + request.conversationId = conversationId; + } + if (previousToken !== undefined) { + if (typeof previousToken !== 'string' || previousToken.length > 4096) return undefined; + request.previousToken = previousToken; + } + return request; +} diff --git a/packages/server/tests/sessions.test.ts b/packages/server/tests/sessions.test.ts new file mode 100644 index 00000000..3ce1995e --- /dev/null +++ b/packages/server/tests/sessions.test.ts @@ -0,0 +1,275 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { NextFunction, Request as ExpressRequest, Response as ExpressResponse } from 'express'; +import type { WirePart } from '@chatcops/core'; +import { scriptedProvider, textStep } from '@chatcops/core/testing/provider'; +import { chatcopsVercelHandler } from '../src/adapters/vercel.js'; +import { chatcopsMiddleware } from '../src/adapters/express.js'; +import { createSessions, SESSION_RESUME_GRACE_SEC } from '../src/security/sessions.js'; +import type { ChatCopsServerConfig } from '../src/config.js'; + +const SECRET = 'a-session-secret-that-is-long-enough-1'; +const OLD_SECRET = 'an-older-session-secret-still-verifying'; +const SITE = 'https://acme.com'; + +/** A /session reply: a token, or an error. */ +interface SessionBody { + token: string; + expiresAt: number; + conversationId: string; + error: { code: string }; +} + +function setup(overrides: Partial = {}) { + const provider = scriptedProvider(Array.from({ length: 10 }, () => textStep('Hello'))); + const handler = chatcopsVercelHandler({ + provider, + systemPrompt: 'Test', + budgets: false, + clientIp: () => '203.0.113.7', + allowedOrigins: [SITE, 'https://other.example'], + sessionSecrets: [SECRET], + ...overrides, + }); + + async function session(body: unknown = {}, origin = SITE) { + const res = await handler( + new Request('http://localhost/api/chat/session', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json', Origin: origin }, + }) + ); + return { status: res.status, body: (await res.json()) as SessionBody, headers: res.headers }; + } + + async function chat(conversationId: string, token?: string, options: { origin?: string; protocol?: number } = {}) { + const headers: Record = { 'Content-Type': 'application/json', Origin: options.origin ?? SITE }; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await handler( + new Request('http://localhost/api/chat', { + method: 'POST', + body: JSON.stringify({ protocol: options.protocol ?? 1, conversationId, message: 'Hi' }), + headers, + }) + ); + return { status: res.status, text: await res.text() }; + } + + return { provider, session, chat }; +} + +function partsOf(text: string): WirePart[] { + return text + .split('\n\n') + .filter((frame) => frame.startsWith('data: {')) + .map((frame) => JSON.parse(frame.slice(6)) as WirePart); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('POST /session', () => { + it('gives a fresh conversation a token that its chat requests can use', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(1_800_000_000_000); + const { session, chat } = setup(); + + const minted = await session(); + expect(minted.status).toBe(200); + expect(minted.headers.get('access-control-allow-origin')).toBe(SITE); + expect(minted.body.conversationId).toMatch(/^[0-9a-f-]{36}$/); + expect(minted.body.expiresAt).toBe(1_800_000_000_000 + 900_000); + + const reply = await chat(minted.body.conversationId, minted.body.token); + expect(reply.status).toBe(200); + expect(partsOf(reply.text)[0]).toMatchObject({ type: 'start', capabilities: { session: true } }); + }); + + it('never hands out a guessed conversation id', async () => { + const { session } = setup(); + const minted = await session({ conversationId: 'someone-elses-conversation' }); + expect(minted.status).toBe(200); + expect(minted.body.conversationId).not.toBe('someone-elses-conversation'); + + const forged = await session({ conversationId: 'someone-elses-conversation', previousToken: 'eyJ.bad.token' }); + expect(forged.body.conversationId).not.toBe('someone-elses-conversation'); + }); + + it('resumes a conversation with its previous token, even expired, within the grace window', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(1_800_000_000_000); + const { session } = setup(); + const first = await session(); + + vi.setSystemTime(1_800_000_000_000 + 7 * 86_400_000); + const resumed = await session({ conversationId: first.body.conversationId, previousToken: first.body.token }); + expect(resumed.body.conversationId).toBe(first.body.conversationId); + expect(resumed.body.token).not.toBe(first.body.token); + + vi.setSystemTime(1_800_000_000_000 + (900 + SESSION_RESUME_GRACE_SEC + 120) * 1000); + const late = await session({ conversationId: first.body.conversationId, previousToken: first.body.token }); + expect(late.body.conversationId).not.toBe(first.body.conversationId); + }); + + it("doesn't resume with a token from another site, or for another conversation", async () => { + const { session } = setup(); + const first = await session(); + const second = await session(); + + const elsewhere = await session({ conversationId: first.body.conversationId, previousToken: first.body.token }, 'https://other.example'); + expect(elsewhere.body.conversationId).not.toBe(first.body.conversationId); + + const swapped = await session({ conversationId: first.body.conversationId, previousToken: second.body.token }); + expect(swapped.body.conversationId).not.toBe(first.body.conversationId); + }); + + it('refuses a body it cannot read, and other sites', async () => { + const { session } = setup(); + expect((await session({ conversationId: 42 })).status).toBe(400); + expect((await session([])).status).toBe(400); + expect((await session({ previousToken: 'x'.repeat(5000) })).status).toBe(400); + expect((await session({}, 'https://evil.example')).status).toBe(403); + }); + + it('is 404 when session tokens are off', async () => { + const { session } = setup({ sessionSecrets: undefined }); + const res = await session(); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('invalid_request'); + }); +}); + +describe('chat requests with session tokens on', () => { + it('need a token: 401 session_required, before the model is called', async () => { + const { chat, provider } = setup(); + const res = await chat('c1'); + expect(res.status).toBe(401); + expect(JSON.parse(res.text).error.code).toBe('session_required'); + expect(provider.calls).toHaveLength(0); + }); + + it('refuse a 0.5 request without a token inside its 200 stream', async () => { + const { chat } = setup(); + const res = await chat('c1', undefined, { protocol: 0 }); + expect(res.status).toBe(200); + expect(res.text).toContain('"error":"provider_error"'); + }); + + it("refuse another conversation's id with your token: 401 conversation_mismatch", async () => { + const { session, chat, provider } = setup(); + const mine = await session(); + const res = await chat('someone-elses-conversation', mine.body.token); + expect(res.status).toBe(401); + expect(JSON.parse(res.text).error.code).toBe('conversation_mismatch'); + expect(provider.calls).toHaveLength(0); + }); + + it('refuse an expired token with token_expired, so the widget fetches a new one', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(1_800_000_000_000); + const { session, chat } = setup(); + const minted = await session(); + vi.setSystemTime(1_800_000_000_000 + 2 * 900_000); + const res = await chat(minted.body.conversationId, minted.body.token); + expect(res.status).toBe(401); + expect(JSON.parse(res.text).error.code).toBe('token_expired'); + }); + + it("refuse a token from another site, or one that isn't signed by us", async () => { + const { session, chat } = setup(); + const minted = await session(); + const elsewhere = await chat(minted.body.conversationId, minted.body.token, { origin: 'https://other.example' }); + expect(JSON.parse(elsewhere.text).error.code).toBe('session_required'); + + const other = setup({ sessionSecrets: ['a-different-secret-that-is-long-enough'] }); + const foreign = await other.session(); + const forged = await chat(foreign.body.conversationId, foreign.body.token); + expect(forged.status).toBe(401); + expect(JSON.parse(forged.text).error.code).toBe('session_required'); + }); + + it('accept tokens signed with an older secret while it is still listed', async () => { + const before = setup({ sessionSecrets: [OLD_SECRET] }); + const minted = await before.session(); + const after = setup({ sessionSecrets: [SECRET, OLD_SECRET] }); + expect((await after.chat(minted.body.conversationId, minted.body.token)).status).toBe(200); + }); + + it('let requests without a token through with requireSession: false, but still check the ones that send one', async () => { + const { session, chat } = setup({ requireSession: false }); + expect((await chat('anonymous')).status).toBe(200); + const minted = await session(); + expect((await chat('not-the-token-conversation', minted.body.token)).status).toBe(401); + }); +}); + +describe('the Express middleware', () => { + function run(config: ChatCopsServerConfig, path: string, body: unknown, headers: Record = {}) { + const chunks: string[] = []; + const res = { + writableFinished: false, + statusCode: 200, + jsonBody: undefined as unknown as SessionBody, + setHeader: vi.fn(), + status(code: number) { + this.statusCode = code; + return this; + }, + json(value: unknown) { + this.jsonBody = value; + }, + write: (chunk: string) => { + chunks.push(chunk); + return true; + }, + end: vi.fn(), + on: vi.fn(), + }; + const req = { method: 'POST', path, body, ip: '127.0.0.1', socket: {}, headers }; + return chatcopsMiddleware(config)(req as unknown as ExpressRequest, res as unknown as ExpressResponse, vi.fn() as unknown as NextFunction).then(() => ({ res, chunks })); + } + + it('answers /session under its mount path and checks the token on chat requests', async () => { + const config: ChatCopsServerConfig = { + provider: scriptedProvider([textStep('Hello')]), + systemPrompt: 'Test', + budgets: false, + sessionSecrets: [SECRET], + }; + const minted = await run(config, '/session', {}); + expect(minted.res.statusCode).toBe(200); + const { conversationId, token } = minted.res.jsonBody; + + const refused = await run(config, '/', { protocol: 1, conversationId, message: 'Hi' }); + expect(refused.res.statusCode).toBe(401); + + const ok = await run(config, '/', { protocol: 1, conversationId, message: 'Hi' }, { authorization: `Bearer ${token}` }); + expect(ok.res.statusCode).toBe(200); + expect(ok.chunks.join('')).toContain('"session":true'); + }); + + it('is 404 at /session when session tokens are off', async () => { + const off = await run({ provider: scriptedProvider([]), systemPrompt: 'Test', budgets: false }, '/session', {}); + expect(off.res.statusCode).toBe(404); + }); +}); + +describe('session config', () => { + it('fails at startup on a secret that is too short, or none at all', () => { + expect(() => createSessions({ sessionSecrets: ['short'] })).toThrow('sessionSecrets[0] is 5 bytes; it needs at least 32'); + expect(() => createSessions({ sessionSecrets: [] })).toThrow('at least one secret'); + expect(() => createSessions({ requireSession: true })).toThrow('needs `sessionSecrets`'); + expect(() => createSessions({ sessionSecrets: [SECRET], sessionTtlSec: 0 })).toThrow('sessionTtlSec'); + expect(createSessions({})).toBeUndefined(); + }); + + it('reads the Bearer scheme in any case', async () => { + const sessions = createSessions({ sessionSecrets: [SECRET] }); + if (!sessions) throw new Error('sessions should be on'); + const minted = await sessions.mint({}, SITE); + const { token, conversationId } = minted.body as SessionBody; + expect(await sessions.check(`bearer ${token}`, SITE, conversationId)).toMatchObject({ ok: true }); + expect(await sessions.check(`Basic ${token}`, SITE, conversationId)).toMatchObject({ ok: false, code: 'session_required' }); + }); +}); diff --git a/website/src/content/docs/server/setup.mdx b/website/src/content/docs/server/setup.mdx index 95735077..a6cf8058 100644 --- a/website/src/content/docs/server/setup.mdx +++ b/website/src/content/docs/server/setup.mdx @@ -108,6 +108,9 @@ interface ChatCopsServerConfig { /** @deprecated Use `allowedOrigins`. */ cors?: string; clientIp?: (request: Request) => string | null | undefined; // fetch handlers, see the fetch runtimes page + sessionSecrets?: string[]; // turns on session tokens, see Session tokens below + sessionTtlSec?: number; // default 900 + requireSession?: boolean; // default true when sessionSecrets is set exposeUsage?: boolean; // token usage in protocol v1 finish parts; default false /** @deprecated Ignored by the server. Use the widget's `strings` option. */ i18n?: { @@ -170,6 +173,33 @@ Requests without an `Origin` header, such as `curl` or a call from your own serv `cors` is the older option for one origin. It still works as a one-item `allowedOrigins`, with a deprecation warning. +## Session tokens + +Without session tokens, anyone who knows a conversation id can keep talking in that conversation, and the model answers with its history in view. Ids are random, so this is about leaked ids and guessing. With `sessionSecrets` set, the chat needs a short-lived token that's bound to one conversation and one site: + +```typescript +sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!], +``` + +Each secret needs at least 32 bytes; `openssl rand -base64 32` makes one. Keep it on the server, never in a `NEXT_PUBLIC_` or `VITE_` variable. The server refuses to start with a shorter one. + +The client asks for a token at the chat URL plus `/session` (`/api/chat/session` for a chat at `/api/chat`): + +``` +POST /api/chat/session +{ "conversationId": "c_8f2", "previousToken": "eyJ..." } + +200 { "token": "eyJ...", "expiresAt": 1792411200000, "conversationId": "c_8f2" } +``` + +Then it sends `Authorization: Bearer ` with each chat request. Tokens last `sessionTtlSec` (15 minutes by default). To keep a conversation, the client sends its id with the token it had for it, which still works up to 30 days after it expired. An id without its token, or with a token for another conversation or site, gets a new conversation instead, so a guessed id is useless. + +With Express, mount the middleware with `app.use('/api/chat', ...)` so `/api/chat/session` reaches it. On Vercel or Next.js, export the same handler from a second route file at `app/api/chat/session/route.ts`. A Cloudflare Worker gets every path already. + +Chat requests without a token get `401 session_required`, an expired token gets `401 token_expired`, and a token for another conversation gets `401 conversation_mismatch`. Widgets before 0.7 don't send tokens, so set `requireSession: false` while they're still around: requests without a token then go through, and the ones that send a token are still checked. + +To change the secret, put the new one first and keep the old one until its tokens have expired: `sessionSecrets: [newSecret, oldSecret]`. New tokens are signed with the first, and any of them verifies. + ## Budgets Budgets cap what replies can cost, so a script hammering your endpoint can't run up the bill. They're counted in `storage`, so they hold across instances. @@ -330,6 +360,9 @@ A request that can't be answered gets a real HTTP status before any stream, with | Status | Code | When | |---|---|---| | 400 | `invalid_request` | The body isn't valid. The message names the fields, never their values. | +| 401 | `session_required` | Session tokens are on and the request has no valid token. | +| 401 | `token_expired` | The session token has expired. Get a new one from `/session`. | +| 401 | `conversation_mismatch` | The session token is for a different conversation. | | 409 | `conversation_busy` | A reply for this conversation is still being written. | | 413 | `payload_too_large` | The body is over 256 KB. With Express, `express.json()` sets the limit (100 KB by default) and answers 413 itself. | | 429 | `rate_limited` | Too many requests from this IP. `Retry-After` has the seconds, and `retryAfterMs` the milliseconds. | From 79a22536406abec96756a36027cec33c8f80f13b Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:23:29 +0530 Subject: [PATCH 63/80] feat(widget): get and send session tokens when the server has them on (#302) --- .changeset/session-endpoint.md | 2 +- .changeset/widget-sessions.md | 5 + packages/server/src/handler.ts | 2 +- packages/server/src/security/sessions.ts | 22 +++- packages/server/tests/sessions.test.ts | 28 +++-- packages/widget/src/api/client.ts | 60 ++++++++-- packages/widget/src/api/types.ts | 2 + packages/widget/src/widget.ts | 2 + packages/widget/tests/api/session.test.ts | 132 ++++++++++++++++++++++ packages/widget/tests/widget.test.ts | 24 ++++ packages/widget/tsconfig.json | 5 +- website/src/content/docs/server/setup.mdx | 4 +- 12 files changed, 262 insertions(+), 26 deletions(-) create mode 100644 .changeset/widget-sessions.md create mode 100644 packages/widget/tests/api/session.test.ts diff --git a/.changeset/session-endpoint.md b/.changeset/session-endpoint.md index 05378410..5d035771 100644 --- a/.changeset/session-endpoint.md +++ b/.changeset/session-endpoint.md @@ -3,4 +3,4 @@ "@chatcops/core": minor --- -Session tokens. With `sessionSecrets` set, `POST /session` gives a short-lived token bound to one conversation and the page's origin, and chat requests need it as `Authorization: Bearer ` (401 `session_required`, `token_expired` or `conversation_mismatch` otherwise). A conversation is resumed only with a token that was issued for it, so a guessed id gets a new conversation. `requireSession: false` lets requests without a token through while older widgets are around. Adds the `conversation_mismatch` error code to protocol v1. +Session tokens. With `sessionSecrets` set, `POST /session` gives a short-lived token bound to one conversation and the page's origin, and chat requests need it as `Authorization: Bearer ` (401 `session_required`, `token_expired` or `conversation_mismatch` otherwise). A conversation that has messages is resumed only with a token that was issued for it, so a guessed id gets a new conversation. An id with nothing stored yet is given out as is. `requireSession: false` lets requests without a token through while older widgets are around. Adds the `conversation_mismatch` error code to protocol v1. diff --git a/.changeset/widget-sessions.md b/.changeset/widget-sessions.md new file mode 100644 index 00000000..d6c003e2 --- /dev/null +++ b/.changeset/widget-sessions.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": minor +--- + +Session tokens. When the server has them on and a chat request gets a 401, the widget gets a token from `/session`, keeps it in `localStorage`, and sends the request again with `Authorization: Bearer`. The saved token lets the server resume the conversation after a reload, and the widget moves to a new conversation id when the server gives one. Servers without session tokens see no change. diff --git a/packages/server/src/handler.ts b/packages/server/src/handler.ts index f574e85e..e4308643 100644 --- a/packages/server/src/handler.ts +++ b/packages/server/src/handler.ts @@ -81,7 +81,7 @@ export function createChatHandler(config: ChatCopsServerConfig) { // rather than as a provider error on the first message. validateTools(toolDefs); const tools = toolRegistry(config.tools); - const sessions = createSessions(config); + const sessions = createSessions(config, async (id) => (await conversations.getMessages(id)).length === 0); const requests: Record = { v1: 0, legacy: 0, 'ai-sdk': 0 }; diff --git a/packages/server/src/security/sessions.ts b/packages/server/src/security/sessions.ts index e1b164bc..db75f4e0 100644 --- a/packages/server/src/security/sessions.ts +++ b/packages/server/src/security/sessions.ts @@ -39,8 +39,15 @@ export interface Sessions { check(authorization: string | null | undefined, origin: string | null, conversationId: string): Promise; } -/** `undefined` when `sessionSecrets` isn't set. Throws at startup on a bad config. */ -export function createSessions(config: SessionConfig, now: () => number = Date.now): Sessions | undefined { +/** + * `undefined` when `sessionSecrets` isn't set. Throws at startup on a bad + * config. `isUnused` says whether a conversation has nothing stored yet. + */ +export function createSessions( + config: SessionConfig, + isUnused: (conversationId: string) => Promise = async () => false, + now: () => number = Date.now +): Sessions | undefined { const secrets = config.sessionSecrets; if (secrets === undefined) { if (config.requireSession) throw new Error('`requireSession` needs `sessionSecrets` to sign tokens with.'); @@ -72,9 +79,12 @@ export function createSessions(config: SessionConfig, now: () => number = Date.n const time = nowSec(); // Resuming a conversation needs a token that was issued for it, from - // this origin. Expired is fine within the grace window. Anything else - // gets a new conversation, so a guessed id is useless. + // this origin. Expired is fine within the grace window. An id with + // nothing stored yet (the widget makes its own) is given out as is, + // since there's nothing in it to read. Anything else gets a new + // conversation, so a guessed or leaked id is useless. let resumed: SessionClaims | undefined; + let cid: string | undefined; if (request.conversationId && request.previousToken) { const result = await verifySession(request.previousToken, await getKeys(), { origin: org, @@ -84,11 +94,13 @@ export function createSessions(config: SessionConfig, now: () => number = Date.n }); if (result.ok) resumed = result.claims; } + if (resumed) cid = resumed.cid; + else if (request.conversationId && (await isUnused(request.conversationId))) cid = request.conversationId; const claims: SessionClaims = { v: 1, sid: resumed?.sid ?? crypto.randomUUID(), - cid: resumed?.cid ?? crypto.randomUUID(), + cid: cid ?? crypto.randomUUID(), org, iat: time, exp: time + ttlSec, diff --git a/packages/server/tests/sessions.test.ts b/packages/server/tests/sessions.test.ts index 3ce1995e..57607508 100644 --- a/packages/server/tests/sessions.test.ts +++ b/packages/server/tests/sessions.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { NextFunction, Request as ExpressRequest, Response as ExpressResponse } from 'express'; -import type { WirePart } from '@chatcops/core'; +import { memoryStorage, type WirePart } from '@chatcops/core'; import { scriptedProvider, textStep } from '@chatcops/core/testing/provider'; import { chatcopsVercelHandler } from '../src/adapters/vercel.js'; import { chatcopsMiddleware } from '../src/adapters/express.js'; @@ -86,21 +86,27 @@ describe('POST /session', () => { expect(partsOf(reply.text)[0]).toMatchObject({ type: 'start', capabilities: { session: true } }); }); - it('never hands out a guessed conversation id', async () => { - const { session } = setup(); - const minted = await session({ conversationId: 'someone-elses-conversation' }); - expect(minted.status).toBe(200); - expect(minted.body.conversationId).not.toBe('someone-elses-conversation'); + it("gives out an id with nothing stored, but never one that has messages", async () => { + const { session, chat } = setup(); + const fresh = await session({ conversationId: 'made-up-by-the-widget' }); + expect(fresh.body.conversationId).toBe('made-up-by-the-widget'); + expect((await chat('made-up-by-the-widget', fresh.body.token)).status).toBe(200); + + const guessed = await session({ conversationId: 'made-up-by-the-widget' }); + expect(guessed.status).toBe(200); + expect(guessed.body.conversationId).not.toBe('made-up-by-the-widget'); - const forged = await session({ conversationId: 'someone-elses-conversation', previousToken: 'eyJ.bad.token' }); - expect(forged.body.conversationId).not.toBe('someone-elses-conversation'); + const forged = await session({ conversationId: 'made-up-by-the-widget', previousToken: 'eyJ.bad.token' }); + expect(forged.body.conversationId).not.toBe('made-up-by-the-widget'); }); it('resumes a conversation with its previous token, even expired, within the grace window', async () => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(1_800_000_000_000); - const { session } = setup(); + // A store that keeps the conversation past the grace window. + const { session, chat } = setup({ storage: memoryStorage({ conversationTtlMs: 365 * 86_400_000 }) }); const first = await session(); + await chat(first.body.conversationId, first.body.token); vi.setSystemTime(1_800_000_000_000 + 7 * 86_400_000); const resumed = await session({ conversationId: first.body.conversationId, previousToken: first.body.token }); @@ -113,10 +119,12 @@ describe('POST /session', () => { }); it("doesn't resume with a token from another site, or for another conversation", async () => { - const { session } = setup(); + const { session, chat } = setup(); const first = await session(); const second = await session(); + // Only ids with messages are protected, so give the first conversation one. + await chat(first.body.conversationId, first.body.token); const elsewhere = await session({ conversationId: first.body.conversationId, previousToken: first.body.token }, 'https://other.example'); expect(elsewhere.body.conversationId).not.toBe(first.body.conversationId); diff --git a/packages/widget/src/api/client.ts b/packages/widget/src/api/client.ts index 6dc3b4c4..77dcb68a 100644 --- a/packages/widget/src/api/client.ts +++ b/packages/widget/src/api/client.ts @@ -13,13 +13,32 @@ export interface SendMessageOptions { timeout?: number; } +/** A session token from `POST /session`, for servers that have them on. */ +interface Session { + token: string; + conversationId: string; +} + export class ChatClient { private apiUrl: string; private timeout: number; + private sessionKey: string; + private saved?: Partial; constructor(options: ChatClientOptions) { this.apiUrl = options.apiUrl; this.timeout = options.timeout ?? 30000; + this.sessionKey = `chatcops-session:${options.apiUrl}`; + } + + /** The last token, kept across page loads so the server can resume the conversation. */ + private lastSession(): Partial { + try { + this.saved ??= JSON.parse(localStorage.getItem(this.sessionKey) || '{}'); + } catch { + this.saved = {}; + } + return this.saved as Partial; } async *sendMessage( @@ -70,13 +89,40 @@ export class ChatClient { resetTimeout(); try { - const response = await fetch(this.apiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - // Servers before 0.6 ignore `protocol` and answer in the old format. - body: JSON.stringify({ protocol: 1, ...request }), - signal: controller.signal, - }); + const post = (url: string, body: object, token?: string) => + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const saved = this.lastSession(); + // Servers before 0.6 ignore `protocol` and answer in the old format. + let response = await post(this.apiUrl, { protocol: 1, ...request }, saved.conversationId === request.conversationId ? saved.token : undefined); + + // Session tokens are on. Get a token for this conversation and try once + // more. The previous token, even expired, lets the server resume the + // conversation; without one it may give the conversation a new id. + if (response.status === 401) { + const minted = await post(this.apiUrl.replace(/\/?(\?.*)?$/, '/session$1'), { + conversationId: request.conversationId, + previousToken: saved.token, + }); + if (minted.ok) { + const session = (await minted.json()) as Session; + this.saved = session; + try { + localStorage.setItem(this.sessionKey, JSON.stringify(session)); + } catch { + // Storage full or unavailable: the token still works on this page. + } + if (session.conversationId !== request.conversationId) { + request = { ...request, conversationId: session.conversationId }; + yield { conversationId: session.conversationId }; + } + response = await post(this.apiUrl, { protocol: 1, ...request }, session.token); + } + } if (!response.ok) { await cleanup(); diff --git a/packages/widget/src/api/types.ts b/packages/widget/src/api/types.ts index 1c58ae4d..ae7ff5aa 100644 --- a/packages/widget/src/api/types.ts +++ b/packages/widget/src/api/types.ts @@ -15,6 +15,8 @@ export interface WidgetChatRequest { } export interface WidgetChatChunk { + /** The server gave this conversation a new id along with its session token. */ + conversationId?: string; /** The id the server saves the reply under (protocol v1). */ messageId?: string; content?: string; diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index c054f05e..23637acd 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -510,6 +510,8 @@ export class Widget { break; } + if (chunk.conversationId) this.conversationId = chunk.conversationId; + // The reply is saved under the server's id, so regenerate can name it. if (chunk.messageId) { assistantMsg.id = chunk.messageId; diff --git a/packages/widget/tests/api/session.test.ts b/packages/widget/tests/api/session.test.ts new file mode 100644 index 00000000..c9104fd2 --- /dev/null +++ b/packages/widget/tests/api/session.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChatClient } from '../../src/api/client.js'; +import type { WidgetChatChunk } from '../../src/api/types.js'; + +const API = 'https://api.test/chat'; +const KEY = `chatcops-session:${API}`; + +const v1Reply = () => + new Response( + [ + 'data: {"type":"start","protocol":1,"conversationId":"c","messageId":"m1","capabilities":{"session":true}}', + 'data: {"type":"text-delta","id":"t1","delta":"Hi"}', + 'data: {"type":"finish","reason":"stop"}', + 'data: [DONE]', + '', + ].join('\n\n'), + { status: 200 } + ); +const unauthorized = (code = 'session_required') => new Response(JSON.stringify({ error: { code } }), { status: 401 }); +const minted = (conversationId: string, token = 'tok-1') => + new Response(JSON.stringify({ token, conversationId, expiresAt: Date.now() + 900_000 }), { status: 200 }); + +async function collect(client: ChatClient, conversationId = 'c1'): Promise { + const chunks: WidgetChatChunk[] = []; + for await (const chunk of client.sendMessage({ conversationId, message: 'Hello' })) chunks.push(chunk); + return chunks; +} + +const calls = (fetch: ReturnType) => + fetch.mock.calls.map(([url, init]) => ({ + url: String(url), + auth: (init?.headers as Record | undefined)?.Authorization, + body: JSON.parse(String(init?.body)), + })); + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('ChatClient with session tokens', () => { + it('gets a token on 401, retries once with it, and keeps it for the next message', async () => { + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(minted('c1')) + .mockResolvedValueOnce(v1Reply()) + .mockResolvedValueOnce(v1Reply()); + const client = new ChatClient({ apiUrl: API }); + + expect((await collect(client)).slice(0, 3)).toEqual([{ messageId: 'm1' }, { content: 'Hi' }, { done: true }]); + await collect(client); + + expect(calls(fetch as never)).toEqual([ + { url: API, auth: undefined, body: expect.objectContaining({ conversationId: 'c1' }) }, + { url: 'https://api.test/chat/session', auth: undefined, body: { conversationId: 'c1' } }, + { url: API, auth: 'Bearer tok-1', body: expect.objectContaining({ conversationId: 'c1' }) }, + { url: API, auth: 'Bearer tok-1', body: expect.objectContaining({ conversationId: 'c1' }) }, + ]); + expect(JSON.parse(localStorage.getItem(KEY) ?? '{}')).toMatchObject({ token: 'tok-1', conversationId: 'c1' }); + }); + + it('sends the saved token to resume the conversation after a reload, even once it has expired', async () => { + localStorage.setItem(KEY, JSON.stringify({ token: 'old', conversationId: 'c1' })); + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized('token_expired')) + .mockResolvedValueOnce(minted('c1', 'new')) + .mockResolvedValueOnce(v1Reply()); + + await collect(new ChatClient({ apiUrl: API })); + + const [first, session, retry] = calls(fetch as never); + expect(first.auth).toBe('Bearer old'); + expect(session.body).toEqual({ conversationId: 'c1', previousToken: 'old' }); + expect(retry.auth).toBe('Bearer new'); + }); + + it("doesn't send another conversation's token, and moves to the id the server gives", async () => { + localStorage.setItem(KEY, JSON.stringify({ token: 'old', conversationId: 'c0' })); + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(minted('server-id')) + .mockResolvedValueOnce(v1Reply()); + + const chunks = await collect(new ChatClient({ apiUrl: API })); + + expect(chunks[0]).toEqual({ conversationId: 'server-id' }); + const [first, , retry] = calls(fetch as never); + expect(first.auth).toBeUndefined(); + expect(retry.body.conversationId).toBe('server-id'); + }); + + it('reports the 401 when the server has no session endpoint', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(new Response('{}', { status: 404 })); + expect(await collect(new ChatClient({ apiUrl: API }))).toEqual([{ error: 'HTTP 401' }]); + }); + + it('keeps working when storage is unavailable', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked'); + }); + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(minted('c1')) + .mockResolvedValueOnce(v1Reply()) + .mockResolvedValueOnce(v1Reply()); + const client = new ChatClient({ apiUrl: API }); + + expect((await collect(client)).at(-1)).toEqual({ done: true }); + await collect(client); + expect(calls(fetch as never)[3].auth).toBe('Bearer tok-1'); + }); + + it.each([ + ['/api/chat', '/api/chat/session'], + ['/api/chat/', '/api/chat/session'], + ['https://api.test/chat?site=acme', 'https://api.test/chat/session?site=acme'], + ])('asks %s/session for the token', async (apiUrl, sessionUrl) => { + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(new Response('{}', { status: 404 })); + await collect(new ChatClient({ apiUrl })); + expect(String(fetch.mock.calls[1][0])).toBe(sessionUrl); + }); +}); diff --git a/packages/widget/tests/widget.test.ts b/packages/widget/tests/widget.test.ts index e300b82a..0bce5b4b 100644 --- a/packages/widget/tests/widget.test.ts +++ b/packages/widget/tests/widget.test.ts @@ -40,6 +40,30 @@ describe('Widget', () => { vi.restoreAllMocks(); }); + it('moves to the conversation id the server gives with a session token, and saves history under it', async () => { + const reply = [ + 'data: {"type":"start","protocol":1,"conversationId":"server-id","messageId":"m1"}', + 'data: {"type":"text-delta","id":"t1","delta":"Hello"}', + 'data: {"type":"finish","reason":"stop"}', + 'data: [DONE]', + '', + ].join('\n\n'); + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response('{"error":{"code":"session_required"}}', { status: 401 })) + .mockResolvedValueOnce(new Response('{"token":"tok","conversationId":"server-id","expiresAt":0}', { status: 200 })) + .mockResolvedValueOnce(new Response(reply, { status: 200 })) + .mockResolvedValueOnce(new Response(reply, { status: 200 })); + const widget = new Widget({ apiUrl: 'https://api.test/chat' }); + widget.init(); + + await getInternals(widget).handleSend('Hi'); + await getInternals(widget).handleSend('Again'); + + expect(JSON.parse(String(fetch.mock.calls[3][1]?.body)).conversationId).toBe('server-id'); + expect(JSON.parse(localStorage.getItem('chatcops_conversation') ?? '{}').id).toBe('server-id'); + widget.destroy(); + }); + it('does not open after destroy when auto-open is delayed', () => { vi.useFakeTimers(); const onOpen = vi.fn(); diff --git a/packages/widget/tsconfig.json b/packages/widget/tsconfig.json index eb60ba49..5a4f2037 100644 --- a/packages/widget/tsconfig.json +++ b/packages/widget/tsconfig.json @@ -3,7 +3,10 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", - "lib": ["ES2022", "DOM", "DOM.Iterable"] + "lib": ["ES2022", "DOM", "DOM.Iterable"], + // Class fields become plain assignments in the constructor, without the + // bundler's __publicField helper on every field. Saves bytes in the ESM build. + "useDefineForClassFields": false }, "include": ["src"] } diff --git a/website/src/content/docs/server/setup.mdx b/website/src/content/docs/server/setup.mdx index a6cf8058..70245a0e 100644 --- a/website/src/content/docs/server/setup.mdx +++ b/website/src/content/docs/server/setup.mdx @@ -192,7 +192,9 @@ POST /api/chat/session 200 { "token": "eyJ...", "expiresAt": 1792411200000, "conversationId": "c_8f2" } ``` -Then it sends `Authorization: Bearer ` with each chat request. Tokens last `sessionTtlSec` (15 minutes by default). To keep a conversation, the client sends its id with the token it had for it, which still works up to 30 days after it expired. An id without its token, or with a token for another conversation or site, gets a new conversation instead, so a guessed id is useless. +Then it sends `Authorization: Bearer ` with each chat request. Tokens last `sessionTtlSec` (15 minutes by default). To keep a conversation, the client sends its id with the token it had for it, which still works up to 30 days after it expired. An id that has nothing stored yet is given out as is, since there's nothing in it to read. An id that has messages, sent without its token or with a token for another conversation or site, gets a new conversation instead, so a guessed or leaked id is useless. + +The widget does all of this on its own from 0.7. When a chat request gets a 401, it asks `/session` for a token, keeps it in `localStorage` and sends the request again. When the server answers with a new conversation id, the widget moves to it. With Express, mount the middleware with `app.use('/api/chat', ...)` so `/api/chat/session` reaches it. On Vercel or Next.js, export the same handler from a second route file at `app/api/chat/session/route.ts`. A Cloudflare Worker gets every path already. From 545963f3d651d0a5fd91ddef69e80a6f0bea9cb6 Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:33 +0530 Subject: [PATCH 64/80] feat(server): check Cloudflare Turnstile before issuing a session (#303) --- .changeset/turnstile-session.md | 7 ++ packages/core/src/protocol/errors.ts | 1 + packages/core/src/protocol/v1.ts | 1 + packages/core/tests/protocol/errors.test.ts | 1 + packages/core/tests/protocol/legacy.test.ts | 1 + packages/server/src/adapters/express.ts | 2 +- packages/server/src/config.ts | 8 ++ packages/server/src/handler.ts | 4 +- packages/server/src/http.ts | 2 +- packages/server/src/security/sessions.ts | 47 ++++++++++-- packages/server/tests/sessions.test.ts | 75 +++++++++++++++++++ packages/widget/src/api/client.ts | 29 ++++--- packages/widget/src/api/turnstile.ts | 33 ++++++++ packages/widget/src/index.ts | 1 + packages/widget/src/widget.ts | 7 +- packages/widget/tests/api/session.test.ts | 43 +++++++++++ packages/widget/tests/api/turnstile.test.ts | 63 ++++++++++++++++ website/src/content/docs/server/setup.mdx | 15 ++++ .../src/content/docs/widget/configuration.mdx | 2 + 19 files changed, 322 insertions(+), 20 deletions(-) create mode 100644 .changeset/turnstile-session.md create mode 100644 packages/widget/src/api/turnstile.ts create mode 100644 packages/widget/tests/api/turnstile.test.ts diff --git a/.changeset/turnstile-session.md b/.changeset/turnstile-session.md new file mode 100644 index 00000000..71d7c443 --- /dev/null +++ b/.changeset/turnstile-session.md @@ -0,0 +1,7 @@ +--- +"@chatcops/server": minor +"@chatcops/widget": minor +"@chatcops/core": minor +--- + +Cloudflare Turnstile when a session is issued. With `turnstile: { secretKey }` on the server, `/session` checks a Turnstile token with Cloudflare before signing (401 `turnstile_failed` otherwise, 503 when Cloudflare can't be reached), and a refresh with the previous token skips it. The widget's `turnstileSiteKey` (`data-turnstile-site-key`) loads Cloudflare's script only when it first needs a session and renders the challenge in the page, outside the shadow root. diff --git a/packages/core/src/protocol/errors.ts b/packages/core/src/protocol/errors.ts index 190dd37f..4bb0c2e7 100644 --- a/packages/core/src/protocol/errors.ts +++ b/packages/core/src/protocol/errors.ts @@ -7,6 +7,7 @@ const STATUS_BY_CODE: Record = { session_required: 401, token_expired: 401, conversation_mismatch: 401, + turnstile_failed: 401, origin_not_allowed: 403, conversation_busy: 409, payload_too_large: 413, diff --git a/packages/core/src/protocol/v1.ts b/packages/core/src/protocol/v1.ts index 630bdcd6..ab23dca7 100644 --- a/packages/core/src/protocol/v1.ts +++ b/packages/core/src/protocol/v1.ts @@ -21,6 +21,7 @@ export type WireErrorCode = | 'session_required' | 'token_expired' | 'conversation_mismatch' + | 'turnstile_failed' | 'origin_not_allowed' | 'conversation_busy' | 'payload_too_large' diff --git a/packages/core/tests/protocol/errors.test.ts b/packages/core/tests/protocol/errors.test.ts index aeb40e11..e2b09b29 100644 --- a/packages/core/tests/protocol/errors.test.ts +++ b/packages/core/tests/protocol/errors.test.ts @@ -15,6 +15,7 @@ describe('statusForError', () => { session_required: 401, token_expired: 401, conversation_mismatch: 401, + turnstile_failed: 401, origin_not_allowed: 403, conversation_busy: 409, payload_too_large: 413, diff --git a/packages/core/tests/protocol/legacy.test.ts b/packages/core/tests/protocol/legacy.test.ts index 2cafad1b..5e1896d5 100644 --- a/packages/core/tests/protocol/legacy.test.ts +++ b/packages/core/tests/protocol/legacy.test.ts @@ -81,6 +81,7 @@ describe('toLegacyChunk', () => { 'session_required', 'token_expired', 'conversation_mismatch', + 'turnstile_failed', 'origin_not_allowed', 'conversation_busy', 'payload_too_large', diff --git a/packages/server/src/adapters/express.ts b/packages/server/src/adapters/express.ts index adbefce3..e4214f0d 100644 --- a/packages/server/src/adapters/express.ts +++ b/packages/server/src/adapters/express.ts @@ -57,7 +57,7 @@ export function chatcopsMiddleware(config: ChatCopsServerConfig) { res.status(404).json(errorBody('invalid_request', { message: 'Session tokens are off. Set `sessionSecrets` to turn them on.' })); return; } - const reply = await chat.startSession(req.body, origin); + const reply = await chat.startSession(req.body, origin, expressClientIp(req)); res.status(reply.status).json(reply.body); return; } diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index b81e6647..2b8b5c02 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import type { ClientIpResolver } from './client-ip.js'; +import type { TurnstileConfig } from './security/turnstile.js'; import type { AIProvider, Budgets, @@ -101,6 +102,13 @@ export interface ChatCopsServerConfig { * older than 0.7, which don't send tokens, are still out there. */ requireSession?: boolean; + /** + * Cloudflare Turnstile when a session token is issued: `/session` needs a + * Turnstile token and checks it with Cloudflare before signing. Refreshing + * a session with its previous token skips it. Needs `sessionSecrets`, and + * the widget's `turnstileSiteKey`. + */ + turnstile?: TurnstileConfig; /** * Adds the reply's token usage to the `finish` part of protocol v1 * streams. Off by default, since visitors can read the stream. diff --git a/packages/server/src/handler.ts b/packages/server/src/handler.ts index e4308643..fe2de5a9 100644 --- a/packages/server/src/handler.ts +++ b/packages/server/src/handler.ts @@ -387,7 +387,9 @@ export function createChatHandler(config: ChatCopsServerConfig) { purge, /** How many requests asked for each stream format, to tell when 0.5 widgets are gone. */ /** Answers `POST /session`, or `undefined` when session tokens are off. */ - startSession: sessions ? (body: unknown, origin: string | null) => sessions.mint(body, origin) : undefined, + startSession: sessions + ? (body: unknown, origin: string | null, clientIp?: string) => sessions.mint(body, origin, clientIp) + : undefined, protocolStats: () => ({ ...requests }), getAnalytics: () => analytics?.getStats() ?? null, cleanup, diff --git a/packages/server/src/http.ts b/packages/server/src/http.ts index bbcb6513..ee4c3fd5 100644 --- a/packages/server/src/http.ts +++ b/packages/server/src/http.ts @@ -261,7 +261,7 @@ export async function fetchChatResponse( if (!handler.startSession) return json(404, errorBody('invalid_request', { message: 'Session tokens are off. Set `sessionSecrets` to turn them on.' })); const read = await readJsonBody(request, 16 * 1024); if (!read.ok) return json(400, errorBody('invalid_request', { message: 'Invalid session request' })); - const reply = await handler.startSession(read.body, origin); + const reply = await handler.startSession(read.body, origin, options.clientIp(request)); return json(reply.status, reply.body); } diff --git a/packages/server/src/security/sessions.ts b/packages/server/src/security/sessions.ts index db75f4e0..9c1363be 100644 --- a/packages/server/src/security/sessions.ts +++ b/packages/server/src/security/sessions.ts @@ -1,5 +1,6 @@ import { errorBody, type WireErrorBody, type WireErrorCode } from '@chatcops/core'; import { importSessionKey, signSession, verifySession, type SessionClaims } from './session-token.js'; +import { verifyTurnstile, type TurnstileConfig } from './turnstile.js'; /** * Session tokens for the chat endpoint. `POST /session` gives the widget a @@ -19,6 +20,7 @@ export interface SessionConfig { sessionSecrets?: string[]; sessionTtlSec?: number; requireSession?: boolean; + turnstile?: TurnstileConfig; } export type SessionCheck = @@ -34,7 +36,7 @@ export interface Sessions { /** Whether chat requests without a token are refused. */ required: boolean; /** Answers `POST /session`. */ - mint(body: unknown, origin: string | null): Promise; + mint(body: unknown, origin: string | null, clientIp?: string): Promise; /** Checks the `Authorization` header of a chat request for this conversation. */ check(authorization: string | null | undefined, origin: string | null, conversationId: string): Promise; } @@ -51,8 +53,11 @@ export function createSessions( const secrets = config.sessionSecrets; if (secrets === undefined) { if (config.requireSession) throw new Error('`requireSession` needs `sessionSecrets` to sign tokens with.'); + if (config.turnstile) throw new Error('`turnstile` is checked when a session token is issued, so it needs `sessionSecrets`.'); return undefined; } + const turnstile = config.turnstile; + if (turnstile && !turnstile.secretKey) throw new Error('`turnstile` needs its `secretKey`.'); if (!Array.isArray(secrets) || secrets.length === 0) throw new Error('`sessionSecrets` needs at least one secret.'); secrets.forEach((secret, i) => { const bytes = typeof secret === 'string' ? new TextEncoder().encode(secret).byteLength : 0; @@ -72,7 +77,7 @@ export function createSessions( return { required, - async mint(body, origin) { + async mint(body, origin, clientIp) { const request = readMintRequest(body); if (!request) return { status: 400, body: errorBody('invalid_request', { message: 'Invalid session request' }) }; const org = origin ?? ''; @@ -94,6 +99,22 @@ export function createSessions( }); if (result.ok) resumed = result.claims; } + // A refresh was checked when its first token was issued; a new session + // has to pass Turnstile first. + if (turnstile && !resumed) { + const result = await verifyTurnstile(request.turnstileToken, turnstile, { + remoteIp: clientIp, + idempotencyKey: request.idempotencyKey, + }); + if (!result.ok) { + if (result.code === 'unavailable') { + console.warn('[chatcops] Turnstile could not be reached, so no session token was issued.'); + return { status: 503, body: errorBody('turnstile_failed', { message: 'Turnstile could not be reached. Try again.' }) }; + } + return { status: 401, body: errorBody('turnstile_failed', { message: 'The Turnstile check failed.' }) }; + } + } + if (resumed) cid = resumed.cid; else if (request.conversationId && (await isUnused(request.conversationId))) cid = request.conversationId; @@ -136,11 +157,27 @@ function bearer(header: string | null | undefined): string | undefined { return match?.[1]; } -function readMintRequest(body: unknown): { conversationId?: string; previousToken?: string } | undefined { +interface MintRequest { + conversationId?: string; + previousToken?: string; + turnstileToken?: string; + /** Sent again with a retried request, so Cloudflare accepts the same Turnstile token twice. */ + idempotencyKey?: string; +} + +function readMintRequest(body: unknown): MintRequest | undefined { if (body === undefined || body === null) return {}; if (typeof body !== 'object' || Array.isArray(body)) return undefined; - const { conversationId, previousToken } = body as Record; - const request: { conversationId?: string; previousToken?: string } = {}; + const { conversationId, previousToken, turnstileToken, idempotencyKey } = body as Record; + const request: MintRequest = {}; + if (turnstileToken !== undefined) { + if (typeof turnstileToken !== 'string' || turnstileToken.length > 4096) return undefined; + request.turnstileToken = turnstileToken; + } + if (idempotencyKey !== undefined) { + if (typeof idempotencyKey !== 'string' || idempotencyKey.length > 128) return undefined; + request.idempotencyKey = idempotencyKey; + } if (conversationId !== undefined) { if (typeof conversationId !== 'string' || conversationId.length === 0 || conversationId.length > MAX_ID_LENGTH) return undefined; request.conversationId = conversationId; diff --git a/packages/server/tests/sessions.test.ts b/packages/server/tests/sessions.test.ts index 57607508..3cc16255 100644 --- a/packages/server/tests/sessions.test.ts +++ b/packages/server/tests/sessions.test.ts @@ -137,6 +137,8 @@ describe('POST /session', () => { expect((await session({ conversationId: 42 })).status).toBe(400); expect((await session([])).status).toBe(400); expect((await session({ previousToken: 'x'.repeat(5000) })).status).toBe(400); + expect((await session({ turnstileToken: 7 })).status).toBe(400); + expect((await session({ idempotencyKey: 'x'.repeat(200) })).status).toBe(400); expect((await session({}, 'https://evil.example')).status).toBe(403); }); @@ -212,6 +214,79 @@ describe('chat requests with session tokens on', () => { }); }); +describe('Turnstile when a session is issued', () => { + const turnstile = { secretKey: '1x0000000000000000000000000000000AA' }; + + function siteverify(...replies: Array | Error>) { + const fetch = vi.spyOn(globalThis, 'fetch'); + for (const reply of replies) { + if (reply instanceof Error) fetch.mockRejectedValueOnce(reply); + else fetch.mockResolvedValueOnce(new Response(JSON.stringify(reply), { status: 200 })); + } + return fetch; + } + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('checks the token with Cloudflare, with the IP and idempotency key, before signing', async () => { + const fetch = siteverify({ success: true, hostname: 'acme.com' }); + const { session } = setup({ turnstile }); + const minted = await session({ turnstileToken: 'XXXX.DUMMY.TOKEN', idempotencyKey: 'f0e4c2f7-6a2b-4d3c-9f1e-2a7b8c9d0e1f' }); + + expect(minted.status).toBe(200); + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toBe('https://challenges.cloudflare.com/turnstile/v0/siteverify'); + expect(JSON.parse(String(init?.body))).toEqual({ + secret: turnstile.secretKey, + response: 'XXXX.DUMMY.TOKEN', + remoteip: '203.0.113.7', + idempotency_key: 'f0e4c2f7-6a2b-4d3c-9f1e-2a7b8c9d0e1f', + }); + }); + + it('refuses a missing or failed token with 401 turnstile_failed', async () => { + const fetch = siteverify({ success: false, 'error-codes': ['invalid-input-response'] }); + const { session } = setup({ turnstile }); + + const missing = await session({}); + expect(missing.status).toBe(401); + expect(missing.body.error.code).toBe('turnstile_failed'); + expect(fetch).not.toHaveBeenCalled(); + + const failed = await session({ turnstileToken: 'bad' }); + expect(failed.status).toBe(401); + expect(failed.body.error.code).toBe('turnstile_failed'); + }); + + it('answers 503 when Cloudflare cannot be reached, without issuing a token', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + siteverify(new Error('offline')); + const { session } = setup({ turnstile }); + const res = await session({ turnstileToken: 'XXXX.DUMMY.TOKEN' }); + expect(res.status).toBe(503); + expect(res.body.token).toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Turnstile could not be reached')); + }); + + it('skips Turnstile when a session is refreshed with its previous token', async () => { + const fetch = siteverify({ success: true }); + const { session } = setup({ turnstile }); + const first = await session({ turnstileToken: 'XXXX.DUMMY.TOKEN' }); + const refreshed = await session({ conversationId: first.body.conversationId, previousToken: first.body.token }); + + expect(refreshed.status).toBe(200); + expect(refreshed.body.conversationId).toBe(first.body.conversationId); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('needs sessionSecrets and a secret key', () => { + expect(() => createSessions({ turnstile })).toThrow('needs `sessionSecrets`'); + expect(() => createSessions({ sessionSecrets: [SECRET], turnstile: { secretKey: '' } })).toThrow('needs its `secretKey`'); + }); +}); + describe('the Express middleware', () => { function run(config: ChatCopsServerConfig, path: string, body: unknown, headers: Record = {}) { const chunks: string[] = []; diff --git a/packages/widget/src/api/client.ts b/packages/widget/src/api/client.ts index 77dcb68a..d77e4ce0 100644 --- a/packages/widget/src/api/client.ts +++ b/packages/widget/src/api/client.ts @@ -1,10 +1,13 @@ import type { WirePart } from './protocol.js'; +import { turnstileToken } from './turnstile.js'; import type { WidgetChatRequest, WidgetChatChunk } from './types.js'; export interface ChatClientOptions { apiUrl: string; /** Inactivity timeout in milliseconds. Defaults to 30000. */ timeout?: number; + /** Cloudflare Turnstile site key, when the server checks Turnstile before issuing a session. */ + turnstileSiteKey?: string; } export interface SendMessageOptions { @@ -23,22 +26,20 @@ export class ChatClient { private apiUrl: string; private timeout: number; private sessionKey: string; - private saved?: Partial; + /** The last token, kept across page loads so the server can resume the conversation. */ + private saved: Partial = {}; + private turnstileSiteKey?: string; constructor(options: ChatClientOptions) { this.apiUrl = options.apiUrl; + this.turnstileSiteKey = options.turnstileSiteKey; this.timeout = options.timeout ?? 30000; this.sessionKey = `chatcops-session:${options.apiUrl}`; - } - - /** The last token, kept across page loads so the server can resume the conversation. */ - private lastSession(): Partial { try { - this.saved ??= JSON.parse(localStorage.getItem(this.sessionKey) || '{}'); + this.saved = JSON.parse(localStorage.getItem(this.sessionKey) || '{}'); } catch { - this.saved = {}; + // Storage unavailable: tokens last for this page only. } - return this.saved as Partial; } async *sendMessage( @@ -96,17 +97,20 @@ export class ChatClient { body: JSON.stringify(body), signal: controller.signal, }); - const saved = this.lastSession(); + const saved = this.saved; + const resumable = saved.conversationId === request.conversationId; // Servers before 0.6 ignore `protocol` and answer in the old format. - let response = await post(this.apiUrl, { protocol: 1, ...request }, saved.conversationId === request.conversationId ? saved.token : undefined); + let response = await post(this.apiUrl, { protocol: 1, ...request }, resumable ? saved.token : undefined); // Session tokens are on. Get a token for this conversation and try once // more. The previous token, even expired, lets the server resume the - // conversation; without one it may give the conversation a new id. + // conversation without Turnstile; without one it may give the + // conversation a new id. if (response.status === 401) { const minted = await post(this.apiUrl.replace(/\/?(\?.*)?$/, '/session$1'), { conversationId: request.conversationId, previousToken: saved.token, + turnstileToken: this.turnstileSiteKey && !resumable ? await turnstileToken(this.turnstileSiteKey) : undefined, }); if (minted.ok) { const session = (await minted.json()) as Session; @@ -121,6 +125,9 @@ export class ChatClient { yield { conversationId: session.conversationId }; } response = await post(this.apiUrl, { protocol: 1, ...request }, session.token); + } else { + // A token the server won't resume from: start over, with Turnstile, on the next try. + this.saved = {}; } } diff --git a/packages/widget/src/api/turnstile.ts b/packages/widget/src/api/turnstile.ts new file mode 100644 index 00000000..f6bb1ee1 --- /dev/null +++ b/packages/widget/src/api/turnstile.ts @@ -0,0 +1,33 @@ +let loading: Promise | undefined; + +/** + * A Cloudflare Turnstile token, for the server to check before it issues a + * session. Cloudflare's script loads only when this first runs, and the + * challenge renders in the page itself (not the widget's shadow root), in a + * box that stays empty unless the visitor has to click. + */ +export async function turnstileToken(siteKey: string): Promise { + loading ??= new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + script.onload = resolve; + script.onerror = reject; + document.head.append(script); + }); + await loading; + const box = document.createElement('div'); + box.style.cssText = 'position:fixed;bottom:96px;right:24px;z-index:2147483647'; + document.body.append(box); + try { + return await new Promise((resolve, reject) => { + (window as unknown as { turnstile: { render(box: HTMLElement, options: object): void } }).turnstile.render(box, { + sitekey: siteKey, + appearance: 'interaction-only', + callback: resolve, + 'error-callback': reject, + }); + }); + } finally { + box.remove(); + } +} diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 1213f888..bd57a1a2 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -82,6 +82,7 @@ function autoInit(script: HTMLScriptElement | null): void { ChatCops.init({ apiUrl, + turnstileSiteKey: script.dataset.turnstileSiteKey, mode: mode || undefined, container: container || undefined, theme: { diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 23637acd..b9652f89 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -32,6 +32,11 @@ export interface WidgetConfig { apiUrl: string; /** Request/stream inactivity timeout in milliseconds. Defaults to 30000. */ timeout?: number; + /** + * Your Cloudflare Turnstile site key, when the server has `turnstile` set. + * Cloudflare's script loads only when the widget first needs a session. + */ + turnstileSiteKey?: string; mode?: 'popup' | 'inline'; /** * Names this widget's saved conversation. Set a different one on each widget @@ -119,7 +124,7 @@ export class Widget { constructor(config: WidgetConfig) { this.config = config; - this.client = new ChatClient({ apiUrl: config.apiUrl, timeout: config.timeout }); + this.client = new ChatClient({ apiUrl: config.apiUrl, timeout: config.timeout, turnstileSiteKey: config.turnstileSiteKey }); this.namespace = claimNamespace( config.siteId, config.mode ?? 'popup', diff --git a/packages/widget/tests/api/session.test.ts b/packages/widget/tests/api/session.test.ts index c9104fd2..53e1fe84 100644 --- a/packages/widget/tests/api/session.test.ts +++ b/packages/widget/tests/api/session.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatClient } from '../../src/api/client.js'; import type { WidgetChatChunk } from '../../src/api/types.js'; +import { turnstileToken } from '../../src/api/turnstile.js'; + +vi.mock('../../src/api/turnstile.js', () => ({ turnstileToken: vi.fn(async () => 'ts-token') })); const API = 'https://api.test/chat'; const KEY = `chatcops-session:${API}`; @@ -35,6 +38,7 @@ const calls = (fetch: ReturnType) => beforeEach(() => { localStorage.clear(); + vi.mocked(turnstileToken).mockClear(); }); afterEach(() => { @@ -118,6 +122,45 @@ describe('ChatClient with session tokens', () => { expect(calls(fetch as never)[3].auth).toBe('Bearer tok-1'); }); + it('passes Turnstile for a new session, and skips it when resuming with the saved token', async () => { + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(minted('c1')) + .mockResolvedValueOnce(v1Reply()) + .mockResolvedValueOnce(unauthorized('token_expired')) + .mockResolvedValueOnce(minted('c1', 'tok-2')) + .mockResolvedValueOnce(v1Reply()); + const client = new ChatClient({ apiUrl: API, turnstileSiteKey: 'site-key' }); + + await collect(client); + await collect(client); + + const sessions = calls(fetch as never).filter((call) => call.url.endsWith('/session')); + expect(sessions[0].body).toEqual({ conversationId: 'c1', turnstileToken: 'ts-token' }); + expect(sessions[1].body).toEqual({ conversationId: 'c1', previousToken: 'tok-1' }); + expect(turnstileToken).toHaveBeenCalledTimes(1); + expect(turnstileToken).toHaveBeenCalledWith('site-key'); + }); + + it('drops a saved token the server refused, so the next try runs Turnstile', async () => { + localStorage.setItem(KEY, JSON.stringify({ token: 'too-old', conversationId: 'c1' })); + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized('token_expired')) + .mockResolvedValueOnce(new Response('{"error":{"code":"turnstile_failed"}}', { status: 401 })) + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(minted('c2')) + .mockResolvedValueOnce(v1Reply()); + const client = new ChatClient({ apiUrl: API, turnstileSiteKey: 'site-key' }); + + expect(await collect(client)).toEqual([{ error: 'HTTP 401' }]); + expect(turnstileToken).not.toHaveBeenCalled(); + await collect(client); + + const [, , retryFirst, session] = calls(fetch as never); + expect(retryFirst.auth).toBeUndefined(); + expect(session.body).toEqual({ conversationId: 'c1', turnstileToken: 'ts-token' }); + }); + it.each([ ['/api/chat', '/api/chat/session'], ['/api/chat/', '/api/chat/session'], diff --git a/packages/widget/tests/api/turnstile.test.ts b/packages/widget/tests/api/turnstile.test.ts new file mode 100644 index 00000000..1469e94c --- /dev/null +++ b/packages/widget/tests/api/turnstile.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +type RenderOptions = { sitekey: string; appearance: string; callback(token: string): void; 'error-callback'(): void }; + +/** A fresh module each time, since it loads Cloudflare's script once per page. */ +async function load() { + vi.resetModules(); + return (await import('../../src/api/turnstile.js')).turnstileToken; +} + +/** + * Stands in for the browser loading Cloudflare's script, so nothing is + * fetched: the script tag is kept, not added to the page. + */ +function serveScript(outcome: 'load' | 'error', render?: (box: HTMLElement, options: RenderOptions) => void) { + const scripts: HTMLScriptElement[] = []; + vi.spyOn(document.head, 'append').mockImplementation((...nodes) => { + const script = nodes[0] as HTMLScriptElement; + scripts.push(script); + if (render) (window as unknown as { turnstile: unknown }).turnstile = { render }; + queueMicrotask(() => (outcome === 'load' ? script.onload : script.onerror)?.call(script, new Event(outcome))); + }); + return scripts; +} + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + delete (window as unknown as { turnstile?: unknown }).turnstile; +}); + +describe('turnstileToken', () => { + it('loads Cloudflare once, renders in the page and resolves with the token', async () => { + const turnstileToken = await load(); + const render = vi.fn((box: HTMLElement, options: RenderOptions) => { + expect(box.parentElement).toBe(document.body); + queueMicrotask(() => options.callback(`token-${render.mock.calls.length}`)); + }); + const scripts = serveScript('load', render); + + expect(await turnstileToken('site-key')).toBe('token-1'); + expect(await turnstileToken('site-key')).toBe('token-2'); + + expect(scripts).toHaveLength(1); + expect(scripts[0].src).toBe('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'); + expect(render.mock.calls[0][1]).toMatchObject({ sitekey: 'site-key', appearance: 'interaction-only' }); + // The box is gone once the token is in. + expect(document.body.children).toHaveLength(0); + }); + + it('rejects when the challenge fails, and removes its box', async () => { + const turnstileToken = await load(); + serveScript('load', (_box, options) => queueMicrotask(() => options['error-callback']())); + await expect(turnstileToken('site-key')).rejects.toBeUndefined(); + expect(document.body.children).toHaveLength(0); + }); + + it("rejects when Cloudflare's script can't load", async () => { + const turnstileToken = await load(); + serveScript('error'); + await expect(turnstileToken('site-key')).rejects.toBeInstanceOf(Event); + }); +}); diff --git a/website/src/content/docs/server/setup.mdx b/website/src/content/docs/server/setup.mdx index 70245a0e..d3148221 100644 --- a/website/src/content/docs/server/setup.mdx +++ b/website/src/content/docs/server/setup.mdx @@ -111,6 +111,7 @@ interface ChatCopsServerConfig { sessionSecrets?: string[]; // turns on session tokens, see Session tokens below sessionTtlSec?: number; // default 900 requireSession?: boolean; // default true when sessionSecrets is set + turnstile?: { secretKey: string; action?: string; expectedHostnames?: string[]; timeoutMs?: number }; exposeUsage?: boolean; // token usage in protocol v1 finish parts; default false /** @deprecated Ignored by the server. Use the widget's `strings` option. */ i18n?: { @@ -202,6 +203,19 @@ Chat requests without a token get `401 session_required`, an expired token gets To change the secret, put the new one first and keep the old one until its tokens have expired: `sessionSecrets: [newSecret, oldSecret]`. New tokens are signed with the first, and any of them verifies. +### Turnstile + +A script can still ask for as many sessions as it likes. To make that harder, have `/session` check a [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) token first: + +```typescript +sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!], +turnstile: { secretKey: process.env.TURNSTILE_SECRET_KEY! }, +``` + +Then give the widget the site key, with `data-turnstile-site-key="0x4AAA..."` or `turnstileSiteKey` in `ChatCops.init()`. The widget loads Cloudflare's script only when it first needs a session, so visitors who never open the chat don't load it. The check is invisible for most visitors. When Cloudflare wants a click, the challenge shows near the bottom right corner of the page, outside the widget. A page with a Content Security Policy needs `https://challenges.cloudflare.com` in `script-src` and `frame-src`. + +The server checks the token with Cloudflare, sending the visitor's IP, and refuses a missing or failed one with `401 turnstile_failed`. If Cloudflare can't be reached, `/session` answers `503` and issues nothing. A session refreshed with its previous token skips Turnstile, since it passed when it was first issued. `action`, `expectedHostnames` and `timeoutMs` (default 5000) are optional, and a client that retries `/session` with the same Turnstile token can send the same `idempotencyKey` (a UUID) each time. + ## Budgets Budgets cap what replies can cost, so a script hammering your endpoint can't run up the bill. They're counted in `storage`, so they hold across instances. @@ -365,6 +379,7 @@ A request that can't be answered gets a real HTTP status before any stream, with | 401 | `session_required` | Session tokens are on and the request has no valid token. | | 401 | `token_expired` | The session token has expired. Get a new one from `/session`. | | 401 | `conversation_mismatch` | The session token is for a different conversation. | +| 401 | `turnstile_failed` | `/session` only: the Turnstile token is missing or didn't pass. | | 409 | `conversation_busy` | A reply for this conversation is still being written. | | 413 | `payload_too_large` | The body is over 256 KB. With Express, `express.json()` sets the limit (100 KB by default) and answers 413 itself. | | 429 | `rate_limited` | Too many requests from this IP. `Retry-After` has the seconds, and `retryAfterMs` the milliseconds. | diff --git a/website/src/content/docs/widget/configuration.mdx b/website/src/content/docs/widget/configuration.mdx index 5812ace1..2527bf19 100644 --- a/website/src/content/docs/widget/configuration.mdx +++ b/website/src/content/docs/widget/configuration.mdx @@ -153,6 +153,7 @@ option is available through programmatic configuration. interface WidgetConfig { apiUrl: string; timeout?: number; // Inactivity timeout in ms, default: 30000 + turnstileSiteKey?: string; // Cloudflare Turnstile site key, when the server has `turnstile` set // Display mode mode?: 'popup' | 'inline'; // Default: 'popup' @@ -218,6 +219,7 @@ interface WidgetConfig { | `data-placeholder` | `placeholder` | Input placeholder | | `data-auto-open` | `autoOpen` | Auto-open delay in ms or `true` | | `data-locale` | `locale` | Language code | +| `data-turnstile-site-key` | `turnstileSiteKey` | Cloudflare Turnstile site key, when the server checks Turnstile | ## CSS Custom Properties From a5ee0698220355b5be2f4b5e3e87561e24da3628 Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:38:17 +0530 Subject: [PATCH 65/80] test(e2e): run the browser tests against the real server handler (#304) --- .github/workflows/ci.yml | 5 +- CONTRIBUTING.md | 6 +- e2e/fixtures/sessions.html | 14 +++ e2e/fixtures/slow-tool.html | 19 ++++ e2e/server.mjs | 187 ++++++++++++++++++++++++++-------- e2e/tests/real-server.spec.ts | 59 +++++++++++ e2e/tests/two-widgets.spec.ts | 4 +- e2e/tests/widget.spec.ts | 4 +- 8 files changed, 244 insertions(+), 54 deletions(-) create mode 100644 e2e/fixtures/sessions.html create mode 100644 e2e/fixtures/slow-tool.html create mode 100644 e2e/tests/real-server.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afd171a7..557eb6f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,8 +117,9 @@ jobs: - run: pnpm install --frozen-lockfile - - name: Build the widget - run: pnpm --filter @chatcops/widget build + # e2e/server.mjs runs the real server handler, so build it and core too. + - name: Build the widget and the server + run: pnpm --filter @chatcops/widget --filter "@chatcops/server..." build - name: Install Chromium run: pnpm exec playwright install --with-deps chromium diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b21edf1..3937c2bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -207,15 +207,15 @@ The widget has a size budget: 14 kB gzipped for the script tag bundle (`dist/cha ### Browser tests -The unit tests run in happy-dom. The tests in `e2e/` load the built widget in a real Chromium, against a small mock server that streams canned replies, and run an axe accessibility check: +The unit tests run in happy-dom. The tests in `e2e/` load the built widget in a real Chromium and run an axe accessibility check. `e2e/server.mjs` serves the pages and runs the real `@chatcops/server` handler, with a stand-in provider that answers by the message text (no API keys needed). A second, cross-origin server stands in for a 0.5 server: ```bash -pnpm --filter @chatcops/widget build +pnpm --filter @chatcops/widget --filter "@chatcops/server..." build pnpm exec playwright install chromium # once pnpm e2e ``` -They cover starting from the script tag, streaming, Escape and the tab order, the phone layout, and accessibility. CI runs them in the `e2e` job. Add a test there when you change how the widget behaves in a browser. +They cover starting from the script tag, streaming, Escape and the tab order, the phone layout, accessibility, a reply that fails or is cut off, a slow tool kept alive by pings, session tokens, and a chat API on another origin. CI runs them in the `e2e` job. Add a test there when you change how the widget behaves in a browser. ## Adding a new AI provider diff --git a/e2e/fixtures/sessions.html b/e2e/fixtures/sessions.html new file mode 100644 index 00000000..f147a7c6 --- /dev/null +++ b/e2e/fixtures/sessions.html @@ -0,0 +1,14 @@ + + + + + + Session tokens + + +
+

Session tokens

+
+ + + diff --git a/e2e/fixtures/slow-tool.html b/e2e/fixtures/slow-tool.html new file mode 100644 index 00000000..a9b6f41c --- /dev/null +++ b/e2e/fixtures/slow-tool.html @@ -0,0 +1,19 @@ + + + + + + A slow tool + + +
+

A slow tool

+
+ + + + diff --git a/e2e/server.mjs b/e2e/server.mjs index aff79f76..ca240b32 100644 --- a/e2e/server.mjs +++ b/e2e/server.mjs @@ -1,9 +1,14 @@ -// Static page + mock chat endpoint for the browser tests. -// Serves the built widget, so run `pnpm --filter @chatcops/widget build` first. +// The page server for the browser tests: the built widget, the fixtures, and +// the real @chatcops/server handler on /api/chat with a provider that answers +// by the message text. A second server on another port is a 0.5-style mock, +// for the cross-origin tests. Build the widget, core and server first. import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createChatHandler } from '../packages/server/dist/index.js'; +import { fetchChatResponse } from '../packages/server/dist/http.js'; +import { originPolicy } from '../packages/server/dist/security/origin-policy.js'; const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, '..'); @@ -15,72 +20,164 @@ const files = { '/index.html': ['fixtures/index.html', 'text/html'], '/two-widgets.html': ['fixtures/two-widgets.html', 'text/html'], '/cross-origin.html': ['fixtures/cross-origin.html', 'text/html'], + '/slow-tool.html': ['fixtures/slow-tool.html', 'text/html'], + '/sessions.html': ['fixtures/sessions.html', 'text/html'], }; const apiRequests = []; -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const sleep = (ms, signal) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(signal.reason); + }, { once: true }); + }); -async function readJson(req) { - let body = ''; - for await (const chunk of req) body += chunk; - try { - return JSON.parse(body); - } catch { - return {}; - } +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return Buffer.concat(chunks); } +const REPLY = ['Hello', ' from', ' the', ' test', ' server.']; +/** How long the slow tool takes, well past the slow-tool page's 1.5 s widget timeout. */ +const SLOW_TOOL_MS = 4000; + /** - * The reply depends on the message text, so a test picks the scenario: - * - "fail midway": some text, then an error chunk - * - "cut off": some text, then the connection ends without [DONE] + * A provider that answers by the last user message, so a test picks the + * scenario: + * - "fail midway": some text, then the model request fails + * - "cut off": some text, then nothing (the bridge below ends the response) + * - "slow tool": calls `slow_lookup`, then answers once it returns * - anything else: a normal streamed reply */ -async function chat(req, res, cors = false) { - const body = await readJson(req); - const message = String(body.message ?? ''); - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...(cors ? { 'Access-Control-Allow-Origin': '*' } : {}), - }); +const provider = { + name: 'e2e', + model: 'e2e', + async *step({ messages, signal }) { + const last = messages.at(-1); + const askedText = [...messages].reverse().find((m) => m.role === 'user')?.content ?? ''; + const text = async function* (words) { + for (const delta of words) { + await sleep(40, signal); + yield { type: 'text-delta', delta }; + } + }; - const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`); - const words = ['Hello', ' from', ' the', ' mock', ' server.']; - - if (/fail midway/i.test(message)) { - for (const word of words.slice(0, 3)) { - send({ content: word }); - await sleep(40); + if (/slow tool/i.test(askedText) && !(last?.parts ?? []).some((p) => p.type === 'tool-result')) { + const call = { id: 'call_slow', name: 'slow_lookup', input: {} }; + yield { type: 'tool-call', call }; + yield { + type: 'finish', + reason: 'tool-calls', + usage: { inputTokens: 1, outputTokens: 1 }, + parts: [{ type: 'tool-call', toolCallId: call.id, toolName: call.name, input: {}, executor: 'server', state: 'pending' }], + }; + return; } - send({ error: 'provider_error' }); - res.end('data: [DONE]\n\n'); - return; - } - if (/cut off/i.test(message)) { - for (const word of words.slice(0, 3)) { - send({ content: word }); - await sleep(40); + const words = /slow tool/i.test(askedText) ? ['Found', ' it.'] : /fail midway|cut off/i.test(askedText) ? REPLY.slice(0, 3) : REPLY; + yield* text(words); + if (/fail midway/i.test(askedText)) throw new Error('The e2e provider failed on purpose.'); + if (/cut off/i.test(askedText)) await sleep(60_000, signal); + yield { + type: 'finish', + reason: 'stop', + usage: { inputTokens: 1, outputTokens: 1 }, + parts: [{ type: 'text', text: words.join('') }], + }; + }, +}; + +const slowLookup = { + name: 'slow_lookup', + description: 'Takes a while.', + inputSchema: { type: 'object', properties: {} }, + async execute() { + await sleep(SLOW_TOOL_MS); + return { success: true, data: { found: true } }; + }, +}; + +function chatEndpoint(overrides = {}) { + const config = { + provider, + systemPrompt: 'e2e', + tools: [slowLookup], + toolTimeoutMs: 30_000, + budgets: false, + ...overrides, + }; + const handler = createChatHandler(config); + const origins = originPolicy(config); + return (request) => fetchChatResponse(handler, origins, request, { clientIp: () => undefined, pingIntervalMs: 300 }); +} + +const endpoints = { + '/api/chat': chatEndpoint(), + // Session tokens on, for the session spec. + '/api/secure-chat': chatEndpoint({ sessionSecrets: ['an-e2e-session-secret-of-32-bytes-or-more'] }), +}; + +/** Runs a Fetch API handler for a Node request. */ +async function serveFetch(handle, req, res) { + const body = req.method === 'POST' ? await readBody(req) : undefined; + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) if (typeof value === 'string') headers.set(name, value); + const response = await handle(new Request(`http://localhost:${port}${req.url}`, { method: req.method, headers, body })); + + res.writeHead(response.status, Object.fromEntries(response.headers)); + if (!response.body) return res.end(); + + // "cut off": end the response after the third piece of text, without + // finish or [DONE], the way a proxy timeout or a crash would. + let cutAfter = 0; + try { + cutAfter = /cut off/i.test(JSON.parse(body?.toString() ?? '{}').message) ? 3 : 0; + } catch { + // Not JSON; the handler already answered 400. + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let texts = 0; + res.on('close', () => reader.cancel().catch(() => {})); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + texts += decoder.decode(value).split('"type":"text-delta"').length - 1; + if (cutAfter && texts >= cutAfter) { + await reader.cancel().catch(() => {}); + break; } - res.end(); - return; } + res.end(); +} - for (const word of words) { - send({ content: word }); +/** The 0.5-style mock for the cross-origin tests: CORS for Content-Type only, and the old stream format. */ +async function legacyChat(req, res) { + await readBody(req); + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }); + for (const word of ['Hello', ' from', ' the', ' mock', ' server.']) { + res.write(`data: ${JSON.stringify({ content: word })}\n\n`); await sleep(40); } - send({ done: true }); + res.write(`data: ${JSON.stringify({ done: true })}\n\n`); res.end('data: [DONE]\n\n'); } const server = createServer(async (req, res) => { const url = new URL(req.url ?? '/', `http://localhost:${port}`); try { - if (url.pathname === '/api/chat' && req.method === 'POST') return await chat(req, res); + const endpoint = Object.entries(endpoints).find(([path]) => url.pathname === path || url.pathname === `${path}/session`); + if (endpoint) return await serveFetch(endpoint[1], req, res); if (url.pathname === '/chatcops.esm.js') { const js = await readFile(join(root, 'packages/widget/dist/chatcops.esm.js')); @@ -131,7 +228,7 @@ const apiServer = createServer(async (req, res) => { if (url.pathname === '/api/chat' && req.method === 'POST') { apiRequests.push({ method: 'POST' }); - return await chat(req, res, true); + return await legacyChat(req, res); } res.writeHead(404); diff --git a/e2e/tests/real-server.spec.ts b/e2e/tests/real-server.spec.ts new file mode 100644 index 00000000..7a7b164b --- /dev/null +++ b/e2e/tests/real-server.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; + +// These run against the real @chatcops/server handler (see e2e/server.mjs). +const fab = '.cc-fab'; +const input = '.cc-input'; +const send = '.cc-send-btn'; + +test('the widget speaks protocol v1 with the real server', async ({ page }) => { + await page.goto('/'); + const response = page.waitForResponse((res) => res.url().endsWith('/api/chat') && res.request().method() === 'POST'); + await page.locator(fab).click(); + await page.locator(input).fill('Hi there'); + await page.locator(send).click(); + + const reply = await response; + expect(reply.headers()['x-chatcops-protocol']).toBe('1'); + expect(reply.request().postDataJSON()).toMatchObject({ protocol: 1, message: 'Hi there' }); + await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the test server.'); +}); + +test('pings keep a reply alive through a tool that runs longer than the widget timeout', async ({ page }) => { + test.slow(); + await page.goto('/slow-tool.html'); + await page.locator(fab).click(); + await page.locator(input).fill('Please run the slow tool'); + await page.locator(send).click(); + + const reply = page.locator('.cc-message-assistant').last(); + await expect(reply).toContainText('Found it.', { timeout: 15_000 }); + await expect(reply.locator('.cc-message-error-note')).toHaveCount(0); +}); + +test('with session tokens on, the widget gets a token and sends it', async ({ page }) => { + await page.goto('/sessions.html'); + const requests: Array<{ url: string; status: number; auth?: string }> = []; + page.on('response', (res) => { + if (res.url().includes('/api/secure-chat')) { + requests.push({ url: new URL(res.url()).pathname, status: res.status(), auth: res.request().headers().authorization }); + } + }); + + await page.locator(fab).click(); + await page.locator(input).fill('Hi there'); + await page.locator(send).click(); + await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the test server.'); + + await page.locator(input).fill('Again'); + await page.locator(send).click(); + await expect(page.locator('.cc-message-assistant', { hasText: 'Hello from the test server.' })).toHaveCount(2); + + expect(requests.map(({ url, status }) => `${status} ${url}`)).toEqual([ + '401 /api/secure-chat', + '200 /api/secure-chat/session', + '200 /api/secure-chat', + '200 /api/secure-chat', + ]); + expect(requests[2].auth).toMatch(/^Bearer /); + expect(requests[3].auth).toBe(requests[2].auth); +}); diff --git a/e2e/tests/two-widgets.spec.ts b/e2e/tests/two-widgets.spec.ts index ad81a53d..5c8a2af1 100644 --- a/e2e/tests/two-widgets.spec.ts +++ b/e2e/tests/two-widgets.spec.ts @@ -17,12 +17,12 @@ test('a popup and an inline widget on one page keep separate conversations', asy await inline.locator('.cc-input').fill('From the inline widget'); await inline.locator('.cc-send-btn').click(); - await expect(inline.locator('.cc-message-assistant').last()).toContainText('mock server'); + await expect(inline.locator('.cc-message-assistant').last()).toContainText('test server'); await popup.locator('.cc-fab').click(); await popup.locator('.cc-input').fill('From the popup'); await popup.locator('.cc-send-btn').click(); - await expect(popup.locator('.cc-message-assistant').last()).toContainText('mock server'); + await expect(popup.locator('.cc-message-assistant').last()).toContainText('test server'); expect(conversationIds).toHaveLength(2); expect(conversationIds[0]).not.toBe(conversationIds[1]); diff --git a/e2e/tests/widget.spec.ts b/e2e/tests/widget.spec.ts index 116ba389..a9967142 100644 --- a/e2e/tests/widget.spec.ts +++ b/e2e/tests/widget.spec.ts @@ -25,7 +25,7 @@ test('streams a reply into the panel', async ({ page }) => { await page.locator(send).click(); await expect(page.locator('.cc-message-user').last()).toContainText('Hi there'); - await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the mock server.'); + await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the test server.'); }); test('Escape closes the popup and returns focus to the launcher', async ({ page }) => { @@ -74,7 +74,7 @@ test('has no serious accessibility violations, closed or open', async ({ page }) await expect(page.locator(panel)).toHaveClass(/cc-visible/); await page.locator(input).fill('Hi there'); await page.locator(send).click(); - await expect(page.locator('.cc-message-assistant').last()).toContainText('mock server'); + await expect(page.locator('.cc-message-assistant').last()).toContainText('test server'); const open = await new AxeBuilder({ page }).include('#chatcops-root').analyze(); expect(seriousOrWorse(open.violations)).toEqual([]); From c34a8d4034031f1aeba67fc7f5eb0b8a76c24dc9 Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:43:07 +0530 Subject: [PATCH 66/80] docs: add an upgrade guide from 0.5 (#305) --- .changeset/deprecation-tags.md | 5 ++ packages/core/src/providers/claude.ts | 7 +- packages/core/src/providers/gemini.ts | 7 +- packages/core/src/providers/openai.ts | 7 +- packages/server/src/config.ts | 2 +- website/astro.config.mjs | 1 + .../docs/getting-started/upgrading.mdx | 79 +++++++++++++++++++ website/src/content/docs/server/setup.mdx | 4 +- 8 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 .changeset/deprecation-tags.md create mode 100644 website/src/content/docs/getting-started/upgrading.mdx diff --git a/.changeset/deprecation-tags.md b/.changeset/deprecation-tags.md new file mode 100644 index 00000000..a15f4baf --- /dev/null +++ b/.changeset/deprecation-tags.md @@ -0,0 +1,5 @@ +--- +"@chatcops/core": patch +--- + +`chat()` and `chatSync()` on `ClaudeProvider`, `OpenAIProvider` and `GeminiProvider` are marked `@deprecated` in their types, pointing to `runTurn()`. They behave as before. diff --git a/packages/core/src/providers/claude.ts b/packages/core/src/providers/claude.ts index 5196fd00..42fcded6 100644 --- a/packages/core/src/providers/claude.ts +++ b/packages/core/src/providers/claude.ts @@ -300,12 +300,15 @@ export class ClaudeProvider implements AIProvider { }; } - /** Deprecated: use `runTurn()`. A turn with tools run through `params.toolExecutor`, as text. */ + /** + * A turn with tools run through `params.toolExecutor`, as text. + * @deprecated Use `runTurn()`, which also returns the reply's parts, status and usage. + */ chat(params: ProviderChatParams): AsyncGenerator { return chatViaTurn(this, params); } - /** Deprecated: use `runTurn()`. */ + /** @deprecated Use `runTurn()`. */ chatSync(params: ProviderChatParams): Promise { return chatSyncViaTurn(this, params); } diff --git a/packages/core/src/providers/gemini.ts b/packages/core/src/providers/gemini.ts index 0e133444..ad9ed390 100644 --- a/packages/core/src/providers/gemini.ts +++ b/packages/core/src/providers/gemini.ts @@ -294,12 +294,15 @@ export class GeminiProvider implements AIProvider { }; } - /** Deprecated: use `runTurn()`. A turn with tools run through `params.toolExecutor`, as text. */ + /** + * A turn with tools run through `params.toolExecutor`, as text. + * @deprecated Use `runTurn()`, which also returns the reply's parts, status and usage. + */ chat(params: ProviderChatParams): AsyncGenerator { return chatViaTurn(this, params); } - /** Deprecated: use `runTurn()`. */ + /** @deprecated Use `runTurn()`. */ chatSync(params: ProviderChatParams): Promise { return chatSyncViaTurn(this, params); } diff --git a/packages/core/src/providers/openai.ts b/packages/core/src/providers/openai.ts index 44d5ca79..e7a63ab4 100644 --- a/packages/core/src/providers/openai.ts +++ b/packages/core/src/providers/openai.ts @@ -284,12 +284,15 @@ export class OpenAIProvider implements AIProvider { }; } - /** Deprecated: use `runTurn()`. A turn with tools run through `params.toolExecutor`, as text. */ + /** + * A turn with tools run through `params.toolExecutor`, as text. + * @deprecated Use `runTurn()`, which also returns the reply's parts, status and usage. + */ chat(params: ProviderChatParams): AsyncGenerator { return chatViaTurn(this, params); } - /** Deprecated: use `runTurn()`. */ + /** @deprecated Use `runTurn()`. */ chatSync(params: ProviderChatParams): Promise { return chatSyncViaTurn(this, params); } diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 2b8b5c02..cf762c22 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -99,7 +99,7 @@ export interface ChatCopsServerConfig { /** * Refuse chat requests without a session token (401 `session_required`). * Defaults to true when `sessionSecrets` is set. Set false while widgets - * older than 0.7, which don't send tokens, are still out there. + * older than 0.6, which don't send tokens, are still out there. */ requireSession?: boolean; /** diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 3287f12c..f3592804 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -105,6 +105,7 @@ export default defineConfig({ items: [ { label: 'Quick Start', slug: 'getting-started/quick-start' }, { label: 'Installation', slug: 'getting-started/installation' }, + { label: 'Upgrading from 0.5', slug: 'getting-started/upgrading' }, ], }, { diff --git a/website/src/content/docs/getting-started/upgrading.mdx b/website/src/content/docs/getting-started/upgrading.mdx new file mode 100644 index 00000000..aaf9cc72 --- /dev/null +++ b/website/src/content/docs/getting-started/upgrading.mdx @@ -0,0 +1,79 @@ +--- +title: Upgrading from 0.5 +description: What changes when you move @chatcops/server and @chatcops/widget from 0.5 to 0.6, and @chatcops/core from 0.4 to 0.5. +--- + +This release changes a lot inside the server: how replies are run and stored, how limits are counted, and what goes over the wire. Most apps upgrade without code changes, because old widgets keep working against the new server and the new widget keeps working against an old server. This page lists what can need your attention, then the new options worth turning on. + +The versions are `@chatcops/server` 0.6, `@chatcops/widget` 0.6 and `@chatcops/core` 0.5. They need Node.js 22 or later. + +## Upgrade the server first + +Upgrade the server, deploy it, then update the widget. A widget from 0.5 or earlier sends requests without `"protocol": 1`, and the new server answers those in the exact 0.5 format until 1.0. The server logs one line the first time it does, so you can tell when old widgets are still around (cached pages, a pinned CDN version). + +The 0.6 widget also works against a 0.5 server, so the order isn't critical. It just gets more from a 0.6 one: reply ids that regenerate can name, and a clear "cut off" state when a stream ends early. + +## Things to check + +### Budgets are on by default + +The server now caps what replies can cost: 200 replies per conversation and 2 million tokens per IP per UTC day. A visitor who hits a cap gets an error instead of a reply. The server logs the budgets in force when it starts. To change one, set it in `budgets`. To turn one off, set it to `false`. `budgets: false` turns them all off. See [Budgets](/server/setup/#budgets). + +### Per-IP limits need the visitor's IP + +Rate limits and the per-IP budget key on the visitor's IP. The server now reads it only from headers the platform sets itself. On Vercel and Cloudflare that just works. Express uses `req.ip`, which follows `app.set('trust proxy', ...)`. Anywhere else (Netlify, a Node server behind your own proxy, Bun, Deno), set `clientIp` to read your host's header. Until you do, per-IP limits are skipped, with one warning. The server used to trust the first `X-Forwarded-For` entry, which a visitor can set to anything. See [the visitor's IP](/server/fetch-runtimes/#the-visitors-ip). + +### Provider classes moved to subpaths + +Importing `@chatcops/core` no longer loads the Claude, OpenAI and Gemini SDKs, which cuts serverless cold starts. If you import the classes directly, change the import: + +```typescript +// 0.4 +import { ClaudeProvider } from '@chatcops/core'; + +// 0.5 +import { ClaudeProvider } from '@chatcops/core/providers/claude'; +``` + +The same goes for `@chatcops/core/providers/openai` and `@chatcops/core/providers/gemini`. `createProvider()` and the server's `provider: { type: ... }` config don't change. + +### One reply per conversation at a time + +A second message sent while a reply for the same conversation is still streaming is refused (`409 conversation_busy` in protocol v1, `provider_error` for 0.5 widgets). The widget doesn't let visitors do this, so it only shows up with custom clients that send in parallel. + +### Deprecated options + +These still work. `cors` logs a warning once. + +| Instead of | Use | +|---|---| +| `cors: 'https://acme.com'` | `allowedOrigins: ['https://acme.com']` | +| `conversationStore` | `storage`, from a store package | +| `RateLimiter` export | Nothing. Limits are counted in `storage` now, and `cleanup()` does nothing. | +| `chat()` and `chatSync()` on the built-in providers | `runTurn()` from `@chatcops/core` | +| `regenerate: true` in custom clients | `regenerate: { assistantMessageId }` | + +Setting both `cors` and `allowedOrigins`, or both `conversationStore` and `storage`, is an error. One detail for `cors` users: a request without an `Origin` header no longer gets `Access-Control-Allow-Origin` back. Only browsers read that header, and browsers always send `Origin` on cross-origin requests. + +### Smaller changes + +- Each reply is saved as one assistant message with its parts (text, then each tool call and result), its status and token usage. A store written for 0.4 that only has `get`, `save` and `delete` keeps working; the server logs one warning that two instances writing the same conversation can lose a message, which stores with `append` avoid. +- A reply the visitor walked away from is saved and marked `aborted`, and the model doesn't see it next time. +- Retrying a message that already has a saved reply sends that reply again instead of asking the model for a second one. +- Knowledge base excerpts, the visitor's page and pre-chat details, and the locale line go after the system prompt instead of into it. That lets Claude's prompt cache hold your system prompt, and keeps visitor text out of the instructions. +- Pre-chat form answers are stored with the conversation from its first message and can't change after that. `preChatFields` lists the fields to keep. +- A provider that fails to set up (a missing API key, say) no longer stops the process at startup. It's logged once, and chat requests get `provider_error`. +- `require('@chatcops/widget')` now fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. It never worked (it returned an empty object). Use `import`. +- Tool `execute(input, context)` gets a second argument with the conversation id and an abort signal. Tools that ignore it keep working. + +## Worth turning on + +None of these are on by default. Each one closes a gap that 0.5 had no answer for. + +- `storage`: conversations, limits and the one-reply lock shared by every server instance. On serverless, memory doesn't last between requests, and the server warns about it. See [Storage](/server/storage/). +- `allowedOrigins`: only your sites can put the chat on their pages. See [Allowed origins](/server/setup/#allowed-origins). +- `sessionSecrets`, and optionally `turnstile`: a guessed or leaked conversation id is useless, and a script has to pass Turnstile to start a session. Start with `requireSession: false` while 0.5 widgets are still cached, since they don't send tokens. See [Session tokens](/server/setup/#session-tokens). +- `budgets.globalPerDay`: a daily cap on total spend, in tokens or dollars. There's no default. +- Tools with `inputSchema`: a full JSON Schema, nested objects and arrays included, instead of the flat `parameters` form. See [Tools](/core/tools/). + +For custom clients, the new response format is [protocol v1](/server/setup/#protocol-v1), with typed parts and real HTTP statuses. Apps built on the AI SDK's `useChat` can use [`chatcopsAiSdkHandler`](/server/ai-sdk/). diff --git a/website/src/content/docs/server/setup.mdx b/website/src/content/docs/server/setup.mdx index d3148221..fea65010 100644 --- a/website/src/content/docs/server/setup.mdx +++ b/website/src/content/docs/server/setup.mdx @@ -195,11 +195,11 @@ POST /api/chat/session Then it sends `Authorization: Bearer ` with each chat request. Tokens last `sessionTtlSec` (15 minutes by default). To keep a conversation, the client sends its id with the token it had for it, which still works up to 30 days after it expired. An id that has nothing stored yet is given out as is, since there's nothing in it to read. An id that has messages, sent without its token or with a token for another conversation or site, gets a new conversation instead, so a guessed or leaked id is useless. -The widget does all of this on its own from 0.7. When a chat request gets a 401, it asks `/session` for a token, keeps it in `localStorage` and sends the request again. When the server answers with a new conversation id, the widget moves to it. +The widget does all of this on its own from 0.6. When a chat request gets a 401, it asks `/session` for a token, keeps it in `localStorage` and sends the request again. When the server answers with a new conversation id, the widget moves to it. With Express, mount the middleware with `app.use('/api/chat', ...)` so `/api/chat/session` reaches it. On Vercel or Next.js, export the same handler from a second route file at `app/api/chat/session/route.ts`. A Cloudflare Worker gets every path already. -Chat requests without a token get `401 session_required`, an expired token gets `401 token_expired`, and a token for another conversation gets `401 conversation_mismatch`. Widgets before 0.7 don't send tokens, so set `requireSession: false` while they're still around: requests without a token then go through, and the ones that send a token are still checked. +Chat requests without a token get `401 session_required`, an expired token gets `401 token_expired`, and a token for another conversation gets `401 conversation_mismatch`. Widgets before 0.6 don't send tokens, so set `requireSession: false` while they're still around: requests without a token then go through, and the ones that send a token are still checked. To change the secret, put the new one first and keep the old one until its tokens have expired: `sessionSecrets: [newSecret, oldSecret]`. New tokens are signed with the first, and any of them verifies. From d46e5d3bcf684da666e254de1357c0a094b57aa3 Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:46:58 +0530 Subject: [PATCH 67/80] docs(server): add a security page (#306) --- website/astro.config.mjs | 1 + website/src/content/docs/server/security.mdx | 140 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 website/src/content/docs/server/security.mdx diff --git a/website/astro.config.mjs b/website/astro.config.mjs index f3592804..cee10c91 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -123,6 +123,7 @@ export default defineConfig({ items: [ { label: 'Setup', slug: 'server/setup' }, { label: 'Storage', slug: 'server/storage' }, + { label: 'Security', slug: 'server/security' }, { label: 'Express', slug: 'server/express' }, { label: 'Vercel', slug: 'server/vercel' }, { label: 'Next.js, Hono, Bun, Deno', slug: 'server/fetch-runtimes' }, diff --git a/website/src/content/docs/server/security.mdx b/website/src/content/docs/server/security.mdx new file mode 100644 index 00000000..ca0aa862 --- /dev/null +++ b/website/src/content/docs/server/security.mdx @@ -0,0 +1,140 @@ +--- +title: Security +description: What can go wrong with a public chat endpoint, which ChatCops option stops each problem, and a production config for each platform. +--- + +A chat endpoint is a public URL that calls a paid model with your API key. Anyone who finds it can call it too, from any page or any script. This page covers what that exposes, which option stops each part, and a config to start from on each platform. + +## What each layer stops + +| Layer | Option | Stops | Doesn't stop | +|---|---|---|---| +| Allowed origins | `allowedOrigins` | Other sites putting your bot on their pages | Scripts, which can send any `Origin` | +| Session tokens | `sessionSecrets` | Reading or continuing someone else's conversation with a leaked or guessed id | A script that asks for its own sessions | +| Turnstile | `turnstile` | Cheap scripted session minting | Paid solver farms | +| Rate limits | `rateLimit` (on by default) | One IP sending requests faster than a person types | Many IPs | +| Budgets | `budgets` (on by default) | Any one conversation, IP or day costing more than you set | Nothing. This is the backstop. | + +No single layer is enough on its own. Allowed origins and sessions stop the easy cases. Budgets put a ceiling on the bill whatever gets through, and `budgets.globalPerDay` is the one number that caps it all. There's no default for it, because no single number fits every site, so set one. + +Rate limits and budgets are counted in `storage`. With the default memory storage, each serverless instance counts on its own, and a limit of 30 a minute becomes 30 per instance. Use a [store package](/server/storage/) in production. + +## Always on + +These need no config: + +- Visitor-supplied text (the page URL and title, pre-chat answers) goes into a separate block marked as untrusted data, never into your system prompt. It's capped at 4,000 characters. +- Pre-chat answers are stored with the first message and can't be changed later in the conversation. `preChatFields` drops any field you didn't ask for. +- Request bodies over 256 KB get `413`, and every field has a length cap. +- Tool definitions are checked when the server starts, and a tool call whose arguments aren't valid JSON gets an error result instead of running. +- Webhook URLs that point at private or link-local addresses (including cloud metadata endpoints like `169.254.169.254`) are refused unless the webhook sets `allowPrivateNetwork: true`. Only literal addresses are checked, so also block private ranges at the network level if you can. +- The visitor's IP is read only from headers your platform sets, never from an `X-Forwarded-For` a visitor can send. See [the visitor's IP](/server/fetch-runtimes/#the-visitors-ip). + +## A production config + +These start from the same options. Swap the storage for the one you use. + +### Express + +```typescript +import express from 'express'; +import pg from 'pg'; +import { chatcopsMiddleware } from '@chatcops/server'; +import { postgresStorage } from '@chatcops/store-postgres'; + +const app = express(); +// Behind one proxy (a load balancer, nginx), so req.ip is the visitor's. +app.set('trust proxy', 1); +app.use(express.json()); + +app.use( + '/api/chat', + chatcopsMiddleware({ + provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY! }, + systemPrompt: 'You are the Acme support assistant.', + storage: postgresStorage({ db: new pg.Pool({ connectionString: process.env.DATABASE_URL }) }), + allowedOrigins: ['https://acme.com', 'https://www.acme.com'], + sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!], + budgets: { globalPerDay: { tokens: 20_000_000 } }, + }) +); +``` + +Mount it with `app.use` so `/api/chat/session` reaches it too. + +### Vercel and Next.js + +```typescript +// app/api/chat/route.ts, and the same two lines in app/api/chat/session/route.ts +import { chatcopsVercelHandler } from '@chatcops/server'; +import { redisStorage } from '@chatcops/store-redis'; +import { Redis } from '@upstash/redis'; + +const handler = chatcopsVercelHandler({ + provider: { type: 'openai', apiKey: process.env.OPENAI_API_KEY! }, + systemPrompt: 'You are the Acme support assistant.', + storage: redisStorage({ redis: Redis.fromEnv() }), + allowedOrigins: ['https://acme.com'], + sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!], + turnstile: { secretKey: process.env.TURNSTILE_SECRET_KEY! }, + budgets: { globalPerDay: { tokens: 20_000_000 } }, +}); + +export const POST = handler; +export const OPTIONS = handler; +``` + +On Vercel the handler reads the visitor's IP from Vercel's own headers. On another host, add `clientIp`. + +### Cloudflare Workers + +```typescript +import { chatcopsCloudflareHandler } from '@chatcops/server'; +import { redisStorage } from '@chatcops/store-redis'; +import { Redis } from '@upstash/redis/cloudflare'; + +let chat: ReturnType | undefined; + +export default { + fetch(request: Request, env: Env, ctx: ExecutionContext) { + // Secrets only exist inside a request, so build the handler on the first one. + chat ??= chatcopsCloudflareHandler({ + provider: { type: 'claude', apiKey: env.ANTHROPIC_API_KEY }, + systemPrompt: 'You are the Acme support assistant.', + storage: redisStorage({ redis: Redis.fromEnv(env) }), + allowedOrigins: ['https://acme.com'], + sessionSecrets: [env.CHATCOPS_SESSION_SECRET], + turnstile: { secretKey: env.TURNSTILE_SECRET_KEY }, + budgets: { globalPerDay: { tokens: 20_000_000 } }, + }); + return chat(request, env, ctx); + }, +}; +``` + +The Worker gets every path, so `/session` needs nothing extra. The visitor's IP comes from `CF-Connecting-IP`. + +### The widget + +With `turnstile` on the server, give the widget the site key. Nothing else changes, since the widget handles sessions on its own: + +```html + +``` + +## Before you go live + +- `allowedOrigins` lists your sites, and nothing else. `['*']` is the default. +- `storage` is a store package, so limits and budgets hold across instances. +- `budgets.globalPerDay` is set to a number you're fine paying on a bad day. +- `sessionSecrets` is set, from a secret store, never a `NEXT_PUBLIC_` or `VITE_` variable. Start with `requireSession: false` if widgets before 0.6 are still cached on your pages, and drop it after a few days. +- On a host other than Vercel, Cloudflare or Express, `clientIp` reads your platform's header. Otherwise per-IP limits are skipped. +- If you use the analytics endpoint, `analyticsToken` is set. Without it the endpoint is public. +- Each tool checks its own input. The model writes it, and a visitor can steer the model, so treat a tool's input like a form submission: check ids belong to the visitor, and keep write actions behind your own auth. +- Your system prompt says what the bot should refuse. None of these layers stop a visitor talking the model into off-topic work within your budget. + +The details of each option are on the [server setup page](/server/setup/): [allowed origins](/server/setup/#allowed-origins), [session tokens](/server/setup/#session-tokens), [Turnstile](/server/setup/#turnstile) and [budgets](/server/setup/#budgets). From 12e4dd2af7ad44fccc4cbd8565b0bad5d7e5e5af Mon Sep 17 00:00:00 2001 From: Nalla Prakash <81852531+prakashNalla@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:25:14 +0530 Subject: [PATCH 68/80] docs(core): update providers page for openai-compatible, registerProvider and provider imports (#308) Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> --- .changeset/five-cobras-add.md | 2 + website/src/content/docs/core/providers.mdx | 75 ++++++++++++++++++--- 2 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 .changeset/five-cobras-add.md diff --git a/.changeset/five-cobras-add.md b/.changeset/five-cobras-add.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/five-cobras-add.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/website/src/content/docs/core/providers.mdx b/website/src/content/docs/core/providers.mdx index 5c8c7ee1..1337eede 100644 --- a/website/src/content/docs/core/providers.mdx +++ b/website/src/content/docs/core/providers.mdx @@ -28,8 +28,17 @@ provider: { apiKey: process.env.GOOGLE_AI_API_KEY!, model: 'gemini-3.5-flash-lite', // Default } + +// Any OpenAI-compatible server +provider: { + type: 'openai-compatible', + baseURL: 'http://localhost:11434/v1', + model: 'llama3.3', +} ``` +`apiKey` is required for `claude`, `openai` and `gemini`. Without it, setup fails with an error like `claude requires apiKey`. + ## Default models | Provider | Default model | SDK | Streaming | @@ -44,13 +53,17 @@ Set `model` to use any other model the provider offers. | Option | Providers | Default | What it does | |--------|-----------|---------|--------------| -| `model` | all | see above | The model id. | +| `type` | all | none | `'claude'`, `'openai'`, `'gemini'`, `'openai-compatible'`, or the name of a [custom provider](#custom-providers). | +| `apiKey` | all | none | Required for Claude, OpenAI and Gemini. Optional for `openai-compatible`. | +| `model` | all | see above | The model id. Required for `openai-compatible`. | | `effort` | all | `'low'` | How much effort the model spends per reply: `'low'`, `'medium'`, `'high'`, `'xhigh'` or `'max'`. Lower is faster and cheaper, which suits chat. Sent as `output_config.effort` to Claude 4.6 and later, `reasoning_effort` to OpenAI reasoning models, and `thinkingLevel` to Gemini 3 models. Models without an effort setting ignore it. | -| `baseURL` | all | provider default | A custom API endpoint: a proxy, a gateway, or an OpenAI-compatible server. | +| `baseURL` | all | provider default | A custom API endpoint: a proxy, a gateway, or an OpenAI-compatible server. Required for `openai-compatible`. | +| `headers` | OpenAI, openai-compatible | none | Extra headers sent on every request. | +| `supportsTools` | OpenAI, openai-compatible | `true` | Set to `false` for a model that can't call tools. Tools aren't sent, and one warning is logged. | | `fetch` | all | the global `fetch` | The `fetch` the provider SDK sends requests with. Use it to route requests through a proxy, or to replay recorded responses in tests. | | `promptCaching` | Claude | `true` | Marks the system prompt and tools as a cached prefix, so later requests read them from Claude's prompt cache at about a tenth of the input price. Prompts shorter than the model's cache minimum aren't cached either way. OpenAI and Gemini cache long prompts on their own. | | `organization` | OpenAI | none | Your OpenAI organization id. | -| `maxTokensParam` | OpenAI | `'max_completion_tokens'` | Set to `'max_tokens'` for OpenAI-compatible servers that only accept the old parameter name. | +| `maxTokensParam` | OpenAI, openai-compatible | `'max_completion_tokens'` for `openai`, `'max_tokens'` for `openai-compatible` | The name of the output token limit sent to the API. | The server config's `maxTokens` (default 4096) and `temperature` are passed to the provider on every request. @@ -60,27 +73,35 @@ Claude 5.x models, Claude Opus 4.7 and later, and OpenAI reasoning models (GPT-5 ## OpenAI-compatible servers -Any server that speaks the OpenAI chat completions API works through the `openai` provider with a `baseURL`: +Any server that speaks the OpenAI chat completions API works through the `openai-compatible` provider. `baseURL` and `model` are required, `apiKey` only if the server wants one: ```typescript // Ollama on your own machine provider: { - type: 'openai', - apiKey: 'ollama', // Ollama ignores the key, but the SDK needs one + type: 'openai-compatible', model: 'llama3.3', baseURL: 'http://localhost:11434/v1', } -// OpenRouter +// OpenRouter, with the headers it uses to identify your app provider: { - type: 'openai', + type: 'openai-compatible', apiKey: process.env.OPENROUTER_API_KEY!, model: 'anthropic/claude-haiku-5-5', baseURL: 'https://openrouter.ai/api/v1', + headers: { 'HTTP-Referer': 'https://example.com', 'X-Title': 'My site' }, +} + +// A model that can't call tools +provider: { + type: 'openai-compatible', + model: 'gemma3', + baseURL: 'http://localhost:11434/v1', + supportsTools: false, } ``` -If the server rejects `max_completion_tokens`, add `maxTokensParam: 'max_tokens'`. +`openai-compatible` sends `max_tokens`. If the server wants `max_completion_tokens`, set `maxTokensParam: 'max_completion_tokens'`. ## Conversation history @@ -109,6 +130,32 @@ interface ProviderChatParams { } ``` +## Custom providers + +Register a factory under a name of your own, then use that name as the provider `type`. The factory gets the whole provider config: + +```typescript +import { registerProvider } from '@chatcops/core'; + +registerProvider('my-llm', async (config) => new MyProvider(config)); + +chatcopsMiddleware({ + provider: { type: 'my-llm', model: 'my-model' }, + systemPrompt: '...', +}); +``` + +Registering a built-in name (`claude`, `openai`, `gemini`, `openai-compatible`) throws. Registering the same name again replaces the factory. + +You can also skip the registry and pass a provider object you built yourself as the server's `provider`. Anything with `step()` or `chat()` works: + +```typescript +chatcopsMiddleware({ + provider: new MyProvider({ type: 'my-llm' }), + systemPrompt: '...', +}); +``` + ## Direct provider usage For advanced use cases, you can use providers directly: @@ -137,6 +184,16 @@ const response = await provider.chatSync({ console.log(response); ``` +The provider classes aren't exported from `@chatcops/core`, so importing core doesn't load all three SDKs. Import them from their own paths: + +```typescript +import { ClaudeProvider } from '@chatcops/core/providers/claude'; +import { OpenAIProvider } from '@chatcops/core/providers/openai'; +import { GeminiProvider } from '@chatcops/core/providers/gemini'; + +const provider = new ClaudeProvider({ type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY! }); +``` + ## Running a turn `runTurn()` is what sits between a provider and the server: it calls `step()`, runs the tools the model asks for, and repeats until the model answers, up to 5 tool rounds. It yields the parts the widget receives (`text-delta`, `tool-call`, `lead-captured`, `finish` and so on) and returns the reply as a `ChatMessage`. From 5bc1d4d52f342f3970459a0b2e7d73e8d28b116c Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:28:03 +0530 Subject: [PATCH 69/80] docs(core): run a turn instead of chat() in the direct provider sample (#310) --- website/src/content/docs/core/providers.mdx | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/website/src/content/docs/core/providers.mdx b/website/src/content/docs/core/providers.mdx index 1337eede..47c6676a 100644 --- a/website/src/content/docs/core/providers.mdx +++ b/website/src/content/docs/core/providers.mdx @@ -158,7 +158,7 @@ chatcopsMiddleware({ ## Direct provider usage -For advanced use cases, you can use providers directly: +To use a provider outside the server, create it from the same config and run a turn with it (see [Running a turn](#running-a-turn) below): ```typescript import { createProvider } from '@chatcops/core'; @@ -167,21 +167,6 @@ const provider = await createProvider({ type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY!, }); - -// Streaming -for await (const token of provider.chat({ - messages: [{ id: '1', role: 'user', content: 'Hello!', timestamp: Date.now() }], - systemPrompt: 'You are helpful.', -})) { - process.stdout.write(token); -} - -// Non-streaming -const response = await provider.chatSync({ - messages: [{ id: '1', role: 'user', content: 'Hello!', timestamp: Date.now() }], - systemPrompt: 'You are helpful.', -}); -console.log(response); ``` The provider classes aren't exported from `@chatcops/core`, so importing core doesn't load all three SDKs. Import them from their own paths: @@ -215,7 +200,7 @@ for await (const part of turn) { } ``` -Provider errors don't throw. They end the turn with an `error` part and a `finish` part with reason `error`. `chat()` and `chatSync()` on the built-in providers are now thin wrappers over `runTurn()` that still throw as they did in 0.5. +Provider errors don't throw. They end the turn with an `error` part and a `finish` part with reason `error`. `chat()` and `chatSync()` on the built-in providers are thin wrappers over `runTurn()` that still throw as they did in 0.5. They're deprecated: use `runTurn()`, which also gives you the reply's parts, status and token usage. For tests, `@chatcops/core/testing/provider` has `scriptedProvider()`, which plays back a script of steps instead of calling a model. From 7cc0c9ca691ae97dcdf1405606ca464ad8d02e1e Mon Sep 17 00:00:00 2001 From: Piyush Date: Thu, 22 Oct 2026 13:31:21 +0530 Subject: [PATCH 70/80] docs: document pre-chat form (#309) Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> --- website/astro.config.mjs | 1 + website/src/content/docs/server/setup.mdx | 2 + .../src/content/docs/widget/configuration.mdx | 18 ++++ .../src/content/docs/widget/pre-chat-form.mdx | 89 +++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 website/src/content/docs/widget/pre-chat-form.mdx diff --git a/website/astro.config.mjs b/website/astro.config.mjs index cee10c91..429ae49f 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -112,6 +112,7 @@ export default defineConfig({ label: 'Widget', items: [ { label: 'Configuration', slug: 'widget/configuration' }, + { label: 'Pre-chat Form', slug: 'widget/pre-chat-form' }, { label: 'API Reference', slug: 'widget/api' }, { label: 'Theming', slug: 'widget/theming' }, { label: 'Events', slug: 'widget/events' }, diff --git a/website/src/content/docs/server/setup.mdx b/website/src/content/docs/server/setup.mdx index fea65010..85b1548c 100644 --- a/website/src/content/docs/server/setup.mdx +++ b/website/src/content/docs/server/setup.mdx @@ -325,6 +325,8 @@ The server validates incoming requests with Zod: } ``` +The widget sends pre-chat answers in `userData`. See the [pre-chat form guide](/widget/pre-chat-form) for how the widget collects and submits these answers. Use the `preChatFields` option in [`ChatCopsServerConfig`](#chatcopsserverconfig) to limit which fields the server keeps. + ## Response format The server answers in one of two formats. The widget from 0.6 on sends `"protocol": 1` in the request body and gets protocol v1. Requests without it, from widgets before 0.6 or older custom clients, get the 0.5 format, which keeps working until 1.0. A client can also ask for v1 with an `x-chatcops-protocol: 1` header. The server logs one line the first time it answers in the 0.5 format, so you can tell when old widgets are still around. `chatcopsAiSdkHandler` answers in a third format, the AI SDK's, for apps built on `useChat` (see [AI SDK](/server/ai-sdk/)). diff --git a/website/src/content/docs/widget/configuration.mdx b/website/src/content/docs/widget/configuration.mdx index 2527bf19..acdff89b 100644 --- a/website/src/content/docs/widget/configuration.mdx +++ b/website/src/content/docs/widget/configuration.mdx @@ -188,15 +188,33 @@ interface WidgetConfig { autoOpen?: boolean | number; // Popup mode only. true = immediate, number = delay in ms locale?: string; // Sent to the server with each message. Only English strings are built in strings?: Partial; // Custom i18n overrides + preChatForm?: { + enabled: boolean; + title?: string; + subtitle?: string; + submitLabel?: string; + fields: Array<{ + name: string; + type: 'text' | 'email' | 'select' | 'textarea'; + label: string; + placeholder?: string; + required?: boolean; + options?: string[]; + }>; + }; // Callbacks onOpen?: () => void; onClose?: () => void; onMessage?: (message: MessageData) => void; onError?: (error: Error) => void; + onLeadCaptured?: (leadData?: Record) => void; + onPreChatSubmit?: (data: Record) => void; } ``` +See the [pre-chat form guide](/widget/pre-chat-form) for field behavior, validation, and how answers are sent to the server. The form is configured with JavaScript; it has no `data-*` attributes. + ## Script Tag Attribute Reference | Attribute | Config Key | Description | diff --git a/website/src/content/docs/widget/pre-chat-form.mdx b/website/src/content/docs/widget/pre-chat-form.mdx new file mode 100644 index 00000000..e205f2dc --- /dev/null +++ b/website/src/content/docs/widget/pre-chat-form.mdx @@ -0,0 +1,89 @@ +--- +title: Pre-chat Form +description: Collect visitor details before their first ChatCops message. +--- + +The pre-chat form collects information such as a visitor's name, email, or reason for contacting you before the conversation begins. Configure it in JavaScript with `ChatCops.init()`: + +```typescript +import ChatCops from '@chatcops/widget'; + +ChatCops.init({ + apiUrl: 'https://your-api.com/chat', + preChatForm: { + enabled: true, + title: 'Before we start...', + subtitle: 'Tell us a bit about yourself', + submitLabel: 'Start chat', + fields: [ + { name: 'name', type: 'text', label: 'Name', placeholder: 'Your name', required: true }, + { name: 'email', type: 'email', label: 'Email', placeholder: 'you@example.com' }, + { + name: 'topic', + type: 'select', + label: 'What can we help with?', + options: ['Sales', 'Support', 'Billing'], + }, + { name: 'message', type: 'textarea', label: 'Message', placeholder: 'How can we help?' }, + ], + }, + onPreChatSubmit(data) { + console.log('Pre-chat answers:', data); + }, +}); +``` + +The form appears when there is no saved conversation with messages and the visitor has not already submitted the form during the current browser session. After submission, the widget switches to the chat view. If a field is named `name`, its answer personalizes the welcome message. If a field is named `message`, its answer is sent as the conversation's first chat message immediately after submission. + +## Form options + +| Option | Type | Description | +| --- | --- | --- | +| `enabled` | `boolean` | Required. Set to `true` to show the form before the conversation starts. | +| `title` | `string` | Optional heading above the fields. | +| `subtitle` | `string` | Optional supporting text below the heading. | +| `submitLabel` | `string` | Optional text for the submit button. | +| `fields` | `PreChatField[]` | Fields to show, in display order. Each field has the options below. | + +### Field options + +| Option | Type | Description | +| --- | --- | --- | +| `name` | `string` | Required key for the answer. Use `name` to personalize the welcome message or `message` to start the chat with the visitor's answer. | +| `type` | `'text' \| 'email' \| 'select' \| 'textarea'` | Required input type. Email fields are checked for a valid email format. | +| `label` | `string` | Required text label displayed above the field. | +| `placeholder` | `string` | Optional placeholder. For a select, this replaces the default prompt. If omitted, the select prompt comes from `strings.selectPlaceholder` (English default: `Select {label}...`). | +| `required` | `boolean` | Optional. When `true`, the visitor must enter or select a non-empty value before submitting. Defaults to `false`. | +| `options` | `string[]` | Optional choices for a `select` field. | + +Required fields are checked for non-empty values, and non-empty `email` fields are checked for email format. Invalid fields show an error and the form stays open until corrected. Values are trimmed before they are submitted. + +## What happens to answers + +The widget includes the answers as `userData` on the next chat request. If that request fails, it sends the answers again with the retry. The server accepts at most 50 fields, with field names up to 100 characters and values up to 500 characters. + +The server saves the answers with the conversation's first message and uses those saved answers for the rest of the conversation; later requests cannot replace them. Configure the server's [`preChatFields` allowlist](/server/setup#chatcopsserverconfig) to keep only the fields your server needs. + +## Submission callback and event + +Use `onPreChatSubmit` in the widget config to receive the submitted answers in your application: + +```typescript +ChatCops.init({ + apiUrl: 'https://your-api.com/chat', + preChatForm: { enabled: true, fields: [{ name: 'email', type: 'email', label: 'Email' }] }, + onPreChatSubmit(data) { + analytics.track('pre_chat_submitted', data); + }, +}); +``` + +The widget also emits a `preChatSubmit` event with the same `Record` payload. Subscribe with [`ChatCops.on()`](/widget/events#prechatsubmit): + +```typescript +ChatCops.on('preChatSubmit', (data) => { + console.log('Pre-chat form submitted:', data); +}); +``` + +Pre-chat forms can only be configured through JavaScript. There are no `data-*` attributes for `preChatForm` or its callbacks. From 634dde75c217efed2f712ee982bfbc04e78b4bf8 Mon Sep 17 00:00:00 2001 From: Pratham Pansare <145864145+Pratham2511@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:53:08 +0530 Subject: [PATCH 71/80] test(e2e): cover the published 0.5.0 widget (#311) --- .changeset/upset-plums-attack.md | 2 + e2e/fixtures/legacy-cross-origin.html | 16 ++++++++ e2e/fixtures/legacy.html | 16 ++++++++ e2e/server.mjs | 34 +++++++++++++---- e2e/tests/legacy-widget.spec.ts | 54 +++++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 8 ++++ 7 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 .changeset/upset-plums-attack.md create mode 100644 e2e/fixtures/legacy-cross-origin.html create mode 100644 e2e/fixtures/legacy.html create mode 100644 e2e/tests/legacy-widget.spec.ts diff --git a/.changeset/upset-plums-attack.md b/.changeset/upset-plums-attack.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/upset-plums-attack.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/e2e/fixtures/legacy-cross-origin.html b/e2e/fixtures/legacy-cross-origin.html new file mode 100644 index 00000000..ce4120ee --- /dev/null +++ b/e2e/fixtures/legacy-cross-origin.html @@ -0,0 +1,16 @@ + + + + + + ChatCops cross-origin e2e host page + + +
+

Host page

+

The widget below starts from the script tag alone.

+ +
+ + + diff --git a/e2e/fixtures/legacy.html b/e2e/fixtures/legacy.html new file mode 100644 index 00000000..8e913d26 --- /dev/null +++ b/e2e/fixtures/legacy.html @@ -0,0 +1,16 @@ + + + + + + ChatCops e2e host page + + +
+

Host page

+

The widget below starts from the script tag alone.

+ +
+ + + diff --git a/e2e/server.mjs b/e2e/server.mjs index ca240b32..0e6c3e94 100644 --- a/e2e/server.mjs +++ b/e2e/server.mjs @@ -20,11 +20,14 @@ const files = { '/index.html': ['fixtures/index.html', 'text/html'], '/two-widgets.html': ['fixtures/two-widgets.html', 'text/html'], '/cross-origin.html': ['fixtures/cross-origin.html', 'text/html'], + '/legacy.html': ['fixtures/legacy.html', 'text/html'], + '/legacy-cross-origin.html': ['fixtures/legacy-cross-origin.html', 'text/html'], '/slow-tool.html': ['fixtures/slow-tool.html', 'text/html'], '/sessions.html': ['fixtures/sessions.html', 'text/html'], }; const apiRequests = []; +const legacyApiRequests = []; const sleep = (ms, signal) => new Promise((resolve, reject) => { @@ -157,8 +160,15 @@ async function serveFetch(handle, req, res) { } /** The 0.5-style mock for the cross-origin tests: CORS for Content-Type only, and the old stream format. */ -async function legacyChat(req, res) { - await readBody(req); +async function legacyChat(req, res, requestLog) { + const raw = await readBody(req); + let body; + try { + body = JSON.parse(raw.toString()); + } catch { + body = {}; + } + requestLog.push({ method: 'POST', body }); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -191,6 +201,12 @@ const server = createServer(async (req, res) => { return res.end(js); } + if (url.pathname === '/legacy/chatcops.min.js') { + const js = await readFile(join(root, 'node_modules/chatcops-widget-0-5/dist/chatcops.min.js')); + res.writeHead(200, { 'Content-Type': 'text/javascript' }); + return res.end(js); + } + const file = files[url.pathname]; if (file) { res.writeHead(200, { 'Content-Type': file[1] }); @@ -212,23 +228,25 @@ const apiServer = createServer(async (req, res) => { try { if (url.pathname === '/__test__/api-requests' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); - return res.end(JSON.stringify(apiRequests)); + return res.end(JSON.stringify(url.searchParams.has('legacy') ? legacyApiRequests : apiRequests)); } - if (url.pathname === '/api/chat' && req.method === 'OPTIONS') { + const chatPath = url.pathname === '/api/chat' || url.pathname === '/legacy/api/chat'; + // Isolate legacy traffic so parallel tests don't pollute the existing spec's log. + const requestLog = url.pathname === '/legacy/api/chat' ? legacyApiRequests : apiRequests; + if (chatPath && req.method === 'OPTIONS') { const headers = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }; - apiRequests.push({ method: 'OPTIONS', status: 204, headers }); + requestLog.push({ method: 'OPTIONS', status: 204, headers }); res.writeHead(204, headers); return res.end(); } - if (url.pathname === '/api/chat' && req.method === 'POST') { - apiRequests.push({ method: 'POST' }); - return await legacyChat(req, res); + if (chatPath && req.method === 'POST') { + return await legacyChat(req, res, requestLog); } res.writeHead(404); diff --git a/e2e/tests/legacy-widget.spec.ts b/e2e/tests/legacy-widget.spec.ts new file mode 100644 index 00000000..981b26f9 --- /dev/null +++ b/e2e/tests/legacy-widget.spec.ts @@ -0,0 +1,54 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { expect, test } from '@playwright/test'; + +const legacyPath = '/legacy/chatcops.min.js'; +const crossOriginApi = `http://localhost:${process.env.E2E_API_PORT ?? 4174}/legacy/api/chat`; + +for (const [fixture, expectedReply] of [ + ['legacy.html', 'Hello from the test server.'], + ['legacy-cross-origin.html', 'Hello from the mock server.'], +]) { + test(`published 0.5.0 widget streams a reply on ${fixture}`, async ({ page, request }) => { + const requestedPaths: string[] = []; + page.on('request', (request) => requestedPaths.push(new URL(request.url()).pathname)); + const bundleResponse = page.waitForResponse((response) => new URL(response.url()).pathname === legacyPath); + + await page.goto(`/${fixture}`); + const bundle = await bundleResponse; + expect(bundle.status()).toBe(200); + expect(bundle.request().resourceType()).toBe('script'); + // Check the browser's bytes against the pinned published artifact, not just its URL. + const published = await readFile('node_modules/chatcops-widget-0-5/dist/chatcops.min.js'); + const digest = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); + expect(digest(await bundle.body()), 'must load the published 0.5.0 bundle').toBe(digest(published)); + + await page.locator('.cc-fab').click(); + await expect(page.locator('.cc-panel')).toHaveClass(/cc-visible/); + await page.locator('.cc-input').fill('Hello from the published widget'); + const apiUrl = fixture === 'legacy-cross-origin.html' ? crossOriginApi : new URL('/api/chat', page.url()).href; + const replyResponse = page.waitForResponse( + (response) => response.url() === apiUrl && response.request().method() === 'POST', + ); + await page.locator('.cc-send-btn').click(); + + const reply = await replyResponse; + expect(reply.status()).toBe(200); + const sent = reply.request().postDataJSON(); + expect(sent).not.toHaveProperty('protocol'); + expect(reply.headers()['content-type']).toContain('text/event-stream'); + if (fixture === 'legacy-cross-origin.html') expect(reply.headers()['access-control-allow-origin']).toBe('*'); + await expect(page.locator('.cc-message-user').last()).toContainText('Hello from the published widget'); + await expect(page.locator('.cc-message-assistant').last()).toContainText(expectedReply); + if (fixture === 'legacy-cross-origin.html') { + const log = await request.get(new URL('/__test__/api-requests?legacy=1', apiUrl).href); + expect(log.ok()).toBe(true); + const requests = (await log.json()) as Array<{ method: string; body?: { conversationId?: string; protocol?: number } }>; + const logged = requests.find(({ method, body }) => method === 'POST' && body?.conversationId === sent.conversationId); + if (!logged?.body) throw new Error('Published widget request missing from the API log'); + expect(logged.body).not.toHaveProperty('protocol'); + } + expect(requestedPaths.filter((path) => path === legacyPath)).toHaveLength(1); + expect(requestedPaths).not.toContain('/chatcops.min.js'); + }); +} diff --git a/package.json b/package.json index 0577096f..1ff73529 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@playwright/test": "^1.64.1", "@size-limit/file": "^14.2.0", "@vitest/coverage-v8": "4.1.11", + "chatcops-widget-0-5": "npm:@chatcops/widget@0.5.0", "publint": "^0.3.25", "size-limit": "^14.2.0", "typescript": "^5.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffd96228..2fb700a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@vitest/coverage-v8': specifier: 4.1.11 version: 4.1.11(vitest@4.1.11) + chatcops-widget-0-5: + specifier: npm:@chatcops/widget@0.5.0 + version: '@chatcops/widget@0.5.0' publint: specifier: ^0.3.25 version: 0.3.25 @@ -575,6 +578,9 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@chatcops/widget@0.5.0': + resolution: {integrity: sha512-vCjiLR+nnBNLCYDzFXVWJQTOtyH/j9/TE3hMtJo9uXotMJqjwkJffQt34WQqy4FqS3PATWMh1u1DDaTGPSZR6A==} + '@clack/core@1.5.1': resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} engines: {node: '>= 20.12.0'} @@ -4844,6 +4850,8 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@chatcops/widget@0.5.0': {} + '@clack/core@1.5.1': dependencies: fast-wrap-ansi: 0.2.2 From c2276f4f4be41712bca251b42ad51836ab2ca35a Mon Sep 17 00:00:00 2001 From: kachamsiddarth <86518485+kachamsiddarth@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:24:46 +0530 Subject: [PATCH 72/80] docs(server): add protocol reference for custom clients (#312) * docs(server): add protocol reference for custom clients * docs(server): address protocol reference review --------- Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> --- .changeset/chatty-cows-melt.md | 2 + website/astro.config.mjs | 1 + website/src/content/docs/server/protocol.mdx | 480 +++++++++++++++++++ website/src/content/docs/server/setup.mdx | 53 +- 4 files changed, 485 insertions(+), 51 deletions(-) create mode 100644 .changeset/chatty-cows-melt.md create mode 100644 website/src/content/docs/server/protocol.mdx diff --git a/.changeset/chatty-cows-melt.md b/.changeset/chatty-cows-melt.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/chatty-cows-melt.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 429ae49f..7a0cabfb 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -123,6 +123,7 @@ export default defineConfig({ label: 'Server', items: [ { label: 'Setup', slug: 'server/setup' }, + { label: 'Protocol', slug: 'server/protocol' }, { label: 'Storage', slug: 'server/storage' }, { label: 'Security', slug: 'server/security' }, { label: 'Express', slug: 'server/express' }, diff --git a/website/src/content/docs/server/protocol.mdx b/website/src/content/docs/server/protocol.mdx new file mode 100644 index 00000000..4dabc5c6 --- /dev/null +++ b/website/src/content/docs/server/protocol.mdx @@ -0,0 +1,480 @@ +--- +title: Protocol +description: Reference for ChatCops protocol v1 requests, streaming events, and errors. +--- + +ChatCops protocol v1 is the client-server protocol used by the `@chatcops/widget` (version 0.6 and newer) and custom clients, including mobile apps, command-line tools, backend services, and alternative frontends. + +This reference specifies how clients negotiate protocol v1, the JSON request body schema and limits, Server-Sent Events (SSE) streaming framing, every wire part event, and HTTP error handling. + +--- + +## Protocol Negotiation + +The server supports two primary stream formats for chat endpoints: the modern **protocol v1** stream and the legacy **0.5 format** (which is retained for backwards compatibility with older widgets and scheduled for removal in version 1.0). + +### Requesting Protocol v1 + +A client requests protocol v1 using either of two supported mechanisms: + +1. **Request Body Field (Recommended)**: + Include `"protocol": 1` in the JSON request body: + ```json + { + "protocol": 1, + "conversationId": "test-1", + "message": "Hi" + } + ``` + A request body field is preferred over a custom header because custom headers trigger a CORS preflight request, whereas older servers before v0.6 only allowed `Content-Type` in their preflights. + +2. **Request Header**: + Send the `x-chatcops-protocol` header with value `1` (or any integer 1 or higher): + ```http + x-chatcops-protocol: 1 + ``` + +### Detecting the Protocol v1 Response + +When protocol v1 is active: +- **Successful streaming responses** include the response header: + ```http + x-chatcops-protocol: 1 + ``` + and the stream begins with a `start` event part: `data: {"type":"start",...}`. +- **Cross-origin requests** allow clients to inspect this header because the server exposes `Access-Control-Expose-Headers: Retry-After, X-ChatCops-Protocol`. +- **Pre-stream refusals** are returned immediately as non-200 JSON responses (for example, `400 Bad Request` or `429 Too Many Requests`). + +If neither `"protocol": 1` in the body nor `x-chatcops-protocol: 1` in the request header is supplied, the server falls back to the legacy 0.5 response format (where all refusals and text chunks are framed in a 200 SSE stream). + +--- + +## Request Body + +The server validates chat requests using Zod (`chatRequestSchema` in `@chatcops/server`). + +### Common Request Fields + +| Field | Type | Required? | Constraints & Behavior | Example | +|---|---|---|---|---| +| `protocol` | `number` | Optional | Set to `1` to request protocol v1 streaming. Evaluated as `protocol >= 1`. | `1` | +| `conversationId` | `string` | **Yes** | 1–128 characters. Identifies the conversation thread across turns. | `"c_8f2a1b9c"` | +| `message` | `string` | **Yes** | 1–10,000 characters. The user message text. | `"Hello, what plans do you offer?"` | +| `messageId` | `string` | Optional | 1–128 characters. Client-assigned ID for the user message. Used for idempotency: re-sending an already answered `messageId` replays the saved assistant reply without invoking the AI model again. | `"msg_usr_101"` | +| `regenerate` | `boolean` \| `object` | Optional | `true` replaces the latest assistant reply (or the reply corresponding to `messageId`). `{ assistantMessageId: string }` (1–128 chars) targets a specific assistant message for regeneration. | `{ "assistantMessageId": "msg_ast_202" }` | +| `pageContext` | `object` | Optional | Information about the page where the client is active. Evaluated and placed in system context. | See [Nested Structures](#nested-request-structures) | +| `locale` | `string` | Optional | Max 10 characters. Client browser or device locale (e.g., `"en-US"`, `"es"`). Prompts the model to reply in that language. | `"en-US"` | +| `userData` | `Record` | Optional | Pre-chat form or visitor metadata. Max 50 keys; each key max 100 characters; each value max 500 characters. Locked from the first message in storage. | `{ "plan": "pro", "company": "Acme" }` | + +:::note +The TypeScript interface `ChatRequestV1` in `@chatcops/core/protocol` contains additional fields reserved for a future release (such as `toolResults`, `toolApprovals`, `clientTools`, and `resumeToken`), which the current server ignores. +::: + +### Nested Request Structures + +#### `pageContext` +Provides current webpage context to the assistant. The server validates: +- `url`: Valid URL string (`z.string().url()`). Only up to 2,000 characters reach the prompt. +- `title`: String, maximum 500 characters. +- `description` *(optional)*: String, maximum 1,000 characters. +- `contentSnippet` *(optional)*: String, maximum 2,000 characters. + +```json +{ + "url": "https://example.com/pricing", + "title": "Pricing & Plans - Acme Inc", + "description": "Flexible plans starting at $19/mo.", + "contentSnippet": "Starter: $19/mo. Pro: $49/mo. Enterprise: Custom." +} +``` + +### Example Request + +```json +{ + "protocol": 1, + "conversationId": "c_usr_8829", + "messageId": "msg_9910", + "message": "Can you check the balance for my account?", + "pageContext": { + "url": "https://app.acme.com/dashboard", + "title": "Account Dashboard" + }, + "locale": "en-US", + "userData": { + "tier": "enterprise" + } +} +``` + +--- + +## Stream Format and Framing + +Protocol v1 uses HTTP Server-Sent Events (SSE). Successful streaming replies return with HTTP `200 OK` and headers: + +```http +HTTP/1.1 200 OK +Content-Type: text/event-stream; charset=utf-8 +Cache-Control: no-cache, no-transform +X-Accel-Buffering: no +x-chatcops-protocol: 1 +Access-Control-Expose-Headers: Retry-After, X-ChatCops-Protocol +``` + +### Event Framing Rules + +1. **SSE Data Lines**: Every protocol event is serialized as JSON on a line starting with `data: `, terminated by a double newline (`\n\n`): + ```text + data: {"type":"text-delta","id":"t1","delta":"Hello"} + + ``` +2. **Terminal Stream Marker**: The server terminates every cleanly completed stream with the literal SSE frame: + ```text + data: [DONE] + + ``` +3. **Heartbeat / Ping Comments**: After 10 seconds of silence (e.g. while awaiting a slow tool or backend operation), the server sends an SSE comment line: + ```text + : ping + + ``` + Comments begin with a colon `:` and do not contain JSON data. Custom clients should ignore them; they serve to prevent timeouts in intermediary proxies and firewalls. +4. **Distinction Between `finish` and `[DONE]`**: + - `finish` is a **protocol part** that carries turn termination details (`reason`, token `usage`, pending calls). + - `[DONE]` is the **SSE transport framing marker** indicating that the HTTP stream has ended. + - If a stream closes or reaches EOF without emitting a `finish` event part, the stream was abruptly **interrupted or cut off** (e.g., server process crash, proxy timeout, network drop). +5. **Forward Compatibility**: Clients must ignore unknown or unrecognized `type` values in `WirePart` objects. The protocol design allows introducing new part types without breaking compliant clients. + +--- + +## Part Reference + +The authoritative TypeScript type for protocol events is `WirePart` (exported from `@chatcops/core/protocol`). + +There are 13 variants defined in `WirePart`. Nine are actively emitted by `@chatcops/server` today, while four are reserved for specialized features or future capabilities: + +| Part Type | Purpose | Fields | Status | +|---|---|---|---| +| `start` | First event of every reply. Identifies the turn and conversation. | `protocol: 1`, `conversationId: string`, `messageId: string`, `resumed?: boolean`, `capabilities?: { session?: boolean }` | **Emitted** | +| `text-start` | Marks the beginning of an assistant text block. | `id: string` | **Emitted** | +| `text-delta` | Streams a chunk of text content for a text block. | `id: string`, `delta: string` | **Emitted** | +| `text-end` | Marks the end of an assistant text block. | `id: string` | **Emitted** | +| `source` | Citations or document references used by knowledge base / RAG. | `sourceId: string`, `url: string`, `title?: string`, `snippet?: string` | *Reserved* | +| `tool-call` | Signals that a tool invocation has started. | `toolCallId: string`, `toolName: string`, `input: unknown`, `executor: 'server' \| 'client' \| 'mcp'`, `needsApproval?: boolean`, `title?: string` | **Emitted** | +| `tool-result` | Emits the execution outcome of a tool call. | `toolCallId: string`, `output?: unknown`, `error?: string` | **Emitted** | +| `lead-captured` | Emitted when the built-in lead capture tool successfully gathers visitor information. | `lead: Record` | **Emitted** | +| `handoff` | Human agent handoff status changes. | `status: 'requested' \| 'queued' \| 'connected' \| 'ended'`, `agent?: WireAgent`, `pollAfterMs?: number` | *Reserved* | +| `agent-message` | Direct message from a live human agent. | `messageId: string`, `text: string`, `agent: WireAgent`, `timestamp: number` | *Reserved* | +| `suggestions` | List of clickable quick-reply prompt options. | `items: string[]` | *Reserved* | +| `error` | In-stream failure notification (e.g., provider error or budget exceeded). | `code: WireErrorCode`, `retryable: boolean`, `message?: string`, `retryAfterMs?: number`, `scope?: 'conversation' \| 'session' \| 'ip' \| 'global'` | **Emitted** | +| `finish` | Final protocol event of every assistant turn. | `reason: WireFinishReason`, `usage?: { inputTokens: number; outputTokens: number }`, `pendingToolCalls?: string[]`, `resumeToken?: string` | **Emitted** | + +### WirePart Details and Examples + +#### `start` +Always the first event in a successful stream: +```json +{ + "type": "start", + "protocol": 1, + "conversationId": "c_8f2", + "messageId": "m_91a0c4", + "capabilities": { + "session": true + } +} +``` +- `capabilities.session`: When `true`, indicates that session tokens are enabled on the server. When `requireSession: false` is configured, chat requests without a token can still proceed. + +#### `text-start`, `text-delta`, `text-end` +Surrounds and streams chunks of assistant text: +```json +{ "type": "text-start", "id": "t1" } +``` +```json +{ "type": "text-delta", "id": "t1", "delta": "Hello! How can I help you today?" } +``` +```json +{ "type": "text-end", "id": "t1" } +``` + +#### `source` *(Reserved)* +Represents a source citation: +```json +{ + "type": "source", + "sourceId": "src_faq_1", + "url": "https://example.com/docs/pricing", + "title": "Pricing Documentation", + "snippet": "Our Starter plan begins at $19/mo." +} +``` + +#### `tool-call` +Emitted when the assistant calls a tool: +```json +{ + "type": "tool-call", + "toolCallId": "call_123", + "toolName": "lookup_order", + "input": { "orderId": "ORD-456" }, + "executor": "server" +} +``` +- `executor`: Can be `'server'` (executed on the backend), `'client'` (dispatched to the client app), or `'mcp'` (executed via an MCP server). + +#### `tool-result` +Emitted after tool execution completes: +```json +{ + "type": "tool-result", + "toolCallId": "call_123", + "output": { "status": "shipped", "tracking": "TRK987654" } +} +``` + +#### `lead-captured` +Emitted when visitor contact details are captured: +```json +{ + "type": "lead-captured", + "lead": { + "name": "Jane Doe", + "email": "jane@example.com", + "projectDetails": "Need custom chatbot integration" + } +} +``` + +#### `handoff` *(Reserved)* +Emitted during human escalation: +```json +{ + "type": "handoff", + "status": "queued", + "pollAfterMs": 3000 +} +``` +`status` values: `'requested'`, `'queued'`, `'connected'`, or `'ended'`. + +#### `agent-message` *(Reserved)* +Message sent by a human support agent: +```json +{ + "type": "agent-message", + "messageId": "msg_agent_01", + "text": "Hello, I am joining to help answer your question.", + "agent": { "name": "Sarah", "avatarUrl": "https://example.com/avatars/sarah.png" }, + "timestamp": 1792411200000 +} +``` + +#### `suggestions` *(Reserved)* +Suggested replies for the user: +```json +{ + "type": "suggestions", + "items": ["What are your pricing plans?", "Contact sales", "View API docs"] +} +``` + +#### `error` +Reports an error that occurs after streaming has begun (see [Errors During Streaming](#errors-during-streaming)): +```json +{ + "type": "error", + "code": "provider_error", + "retryable": true, + "message": "The AI provider failed to complete the turn." +} +``` + +#### `finish` +Sent immediately before `data: [DONE]`. Closes the turn: +```json +{ + "type": "finish", + "reason": "stop", + "usage": { + "inputTokens": 142, + "outputTokens": 38 + } +} +``` +- `reason` enum values (`WireFinishReason`): + - **Currently emitted by the server**: + - `'stop'`: The model finished generating naturally. + - `'length'`: Generation was cut off by token limits (`maxTokens`). + - `'error'`: Turn ended due to an error. + - `'aborted'`: Request was aborted by the client or connection termination. + - **Reserved for future releases (not currently emitted)**: + - `'client-tool-calls'` + - `'approval-required'` + - `'handoff'` +- `usage`: Only included when `exposeUsage: true` is configured on the server handler (omitted by default for privacy). + +--- + +## Errors Before Streaming + +When a request cannot be processed, the server rejects it **before** opening an SSE stream. In protocol v1, standard pre-stream refusals return HTTP status codes, CORS headers, and a JSON body matching `WireErrorBody`: + +```http +HTTP/1.1 400 Bad Request +Content-Type: application/json +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Retry-After, X-ChatCops-Protocol + +{ + "error": { + "code": "invalid_request", + "message": "Invalid request: message" + } +} +``` + +### Pre-Stream Error Statuses + +| HTTP Status | Error Code (`WireErrorCode` or string) | Trigger Condition | Message / Response Body | +|---|---|---|---| +| `400` | `invalid_request` | The request body failed Zod schema validation or JSON parsing. | Lists invalid field paths (e.g. `"Invalid request: message"`), never input values. | +| `401` | `session_required` | Session tokens are required and the request had no token, an invalid signature, or a token issued for another origin. | `{"error":{"code":"session_required","message":"..."}}`. Obtain a token for this conversation from `/session`. | +| `401` | `token_expired` | The provided session token has expired. | Request a new token from `/session`. | +| `401` | `conversation_mismatch` | The session token is valid but was issued for a different conversation. | Obtain a token for the current conversation from `/session`. | +| `401` | `turnstile_failed` | `/session` endpoint only: Cloudflare Turnstile verification failed or was missing. | Returned when bot protection challenge fails. | +| `403` | `origin_not_allowed` | The `Origin` header is not permitted by `allowedOrigins`. | Answered before the request body is parsed. Returns legacy format `{"error":"origin_not_allowed"}`. | +| `405` | `Method not allowed` | An HTTP method other than `POST` or `OPTIONS` was used on the chat endpoint. | Answered before the request body is parsed. Returns legacy format `{"error":"Method not allowed"}`. | +| `409` | `conversation_busy` | Another reply is currently being generated for this `conversationId`. | The server locks conversation turns to prevent state races. | +| `413` | `payload_too_large` | The request body exceeds the maximum permitted size (default 256 KB). | Prevents excessive memory allocation. | +| `429` | `rate_limited` | The client IP has exceeded the configured request rate limit. | Includes `retryAfterMs` in body and `Retry-After` in HTTP headers. | +| `500` | `internal_error` | An unexpected server error occurred prior to stream initialization. | Generic catch-all failure. | +| `503` | `provider_unavailable` | The AI provider failed setup or is not reachable (e.g. missing API key). | Server logs the underlying reason at startup. | + +:::note +Responses with status `403 origin_not_allowed` and `405 Method not allowed` retain the legacy `{"error":"..."}` JSON body shape rather than `WireErrorBody` because these checks occur before the request body is read or parsed. Additionally, error codes defined in the protocol error dictionary such as `500` for `tool_error`, `502` for `provider_error`, and `504` for `timeout` are defined for mapping compatibility, but during typical operations they emerge as in-stream `error` parts once a stream has been established. +::: + +### Retry-After Header Behavior + +When `retryAfterMs` is provided on rate-limited responses: +- The JSON response body includes `retryAfterMs` (in milliseconds). +- The HTTP response includes a `Retry-After` header expressing the waiting period in **seconds**, rounded up using `Math.ceil(retryAfterMs / 1000)`: + ```http + Retry-After: 42 + ``` + +--- + +## Errors During Streaming + +If a failure occurs **after** the SSE stream has already opened and sent a `start` part, the server cannot change the HTTP status code (since headers have already been sent). + +Instead, the server emits an `error` part followed by a `finish` part with reason `'error'`, and then closes with `data: [DONE]`: + +```text +data: {"type":"error","code":"provider_error","retryable":true} + +data: {"type":"finish","reason":"error"} + +data: [DONE] + +``` + +### `error` WirePart Fields + +| Field | Type | Description | +|---|---|---| +| `code` | `WireErrorCode` | The error identifier (e.g. `'provider_error'`, `'budget_exceeded'`, `'timeout'`, `'tool_error'`, `'content_filtered'`, `'internal_error'`). | +| `retryable` | `boolean` | **Required.** Indicates whether the client should attempt to retry the request. | +| `message` | `string` | Optional human-readable description of the error. | +| `retryAfterMs` | `number` | Optional delay in milliseconds before retrying. | +| `scope` | `'conversation' \| 'session' \| 'ip' \| 'global'` | Optional scope of the limit. Used when `code` is `'budget_exceeded'` to identify which limit was reached. | + +#### Budget Exceeded In-Stream Error + +When a server budget (token, message, or USD cost limit) is exceeded: +```json +{ + "type": "error", + "code": "budget_exceeded", + "retryable": false, + "scope": "conversation" +} +``` + +--- + +## TypeScript Types + +TypeScript clients can import protocol types directly from the `@chatcops/core` package: + +```typescript +import type { WirePart, ChatRequestV1, WireErrorCode, WireFinishReason } from '@chatcops/core/protocol'; +``` + +`WirePart` is a discriminated union over the `type` property. You can handle events using a standard `switch` statement: + +```typescript +function handlePart(part: WirePart) { + switch (part.type) { + case 'start': + console.log('Started reply:', part.messageId); + break; + case 'text-delta': + process.stdout.write(part.delta); + break; + case 'finish': + console.log('Turn finished:', part.reason); + break; + case 'error': + console.error('Stream error:', part.code); + break; + default: + // Ignore unknown parts for forward compatibility + break; + } +} +``` + +Installing `@chatcops/core` is convenient for TypeScript and Node.js applications, but it is entirely optional. Non-TypeScript clients (such as Swift on iOS, Kotlin on Android, Python, or Go) can consume protocol v1 by parsing the JSON lines directly. + +--- + +## Try It with curl + +You can test a running ChatCops server using `curl`. + +### Command + +```bash +curl -N -X POST http://localhost:3001/chat \ + -H 'Content-Type: application/json' \ + -d '{"protocol":1,"conversationId":"test-1","message":"Hi"}' +``` + +- `-N` (or `--no-buffer`): Disables curl's buffer so streaming chunks print to the terminal immediately as they arrive. +- The server must be running and listening on `http://localhost:3001`. +- On Windows PowerShell, use `curl.exe` to avoid the PowerShell `Invoke-WebRequest` alias. + +### Captured Local Response + +The transcript below was captured from a local Express server running the repository's `scriptedProvider([textStep('Hello!')])` test provider (which requires no external API keys): + +```text +data: {"type":"start","protocol":1,"conversationId":"test-1","messageId":"2eeea2d1-56f9-428b-a880-af83c496a9a1"} + +data: {"type":"text-start","id":"2eeea2d1-56f9-428b-a880-af83c496a9a1-1"} + +data: {"type":"text-delta","id":"2eeea2d1-56f9-428b-a880-af83c496a9a1-1","delta":"Hello!"} + +data: {"type":"text-end","id":"2eeea2d1-56f9-428b-a880-af83c496a9a1-1"} + +data: {"type":"finish","reason":"stop"} + +data: [DONE] +``` + +:::note +Identifiers such as `messageId` and text block `id` are generated at runtime and will vary between requests, but the event structure, sequencing, and terminal `[DONE]` frame will match this transcript. If a response takes longer than 10 seconds, periodic `: ping` comment lines will also appear between events. +::: diff --git a/website/src/content/docs/server/setup.mdx b/website/src/content/docs/server/setup.mdx index 85b1548c..2e61a4d3 100644 --- a/website/src/content/docs/server/setup.mdx +++ b/website/src/content/docs/server/setup.mdx @@ -337,58 +337,9 @@ Each reply is saved as one assistant message with its parts: the text, and every ### Protocol v1 -A reply is a stream of typed parts, one SSE event each: +Protocol v1 is the default stream format for modern ChatCops clients. Replies stream as typed Server-Sent Events (SSE) carrying granular event parts, with pre-stream refusals returned as JSON with standard HTTP error statuses. -``` -Content-Type: text/event-stream; charset=utf-8 -Cache-Control: no-cache, no-transform -X-Accel-Buffering: no -x-chatcops-protocol: 1 - -data: {"type":"start","protocol":1,"conversationId":"c_8f2","messageId":"m_91"} - -data: {"type":"text-start","id":"t1"} - -data: {"type":"text-delta","id":"t1","delta":"Our Starter plan is "} - -data: {"type":"text-delta","id":"t1","delta":"$19 a month."} - -data: {"type":"text-end","id":"t1"} - -data: {"type":"finish","reason":"stop"} - -data: [DONE] -``` - -- `start` comes first, with the id the reply is saved under. -- `text-start`, `text-delta` and `text-end` carry the text. `tool-call` and `tool-result` show the server's tool steps, and `lead-captured` comes when the lead capture tool succeeds. -- `error` reports a failure after the stream has started, such as `{"type":"error","code":"provider_error","retryable":true}`. The codes are `provider_error`, `budget_exceeded` (with the budget's `scope`), `content_filtered`, `tool_error` and `internal_error`. -- `finish` always comes last, with a `reason`: `stop`, `length`, `error` or `aborted`. With `exposeUsage: true` it also has the reply's token `usage`. Visitors can read the stream, so it's off by default. - -Then `data: [DONE]` ends the stream. A stream that ends without `finish` was cut off, and the widget shows an error with a Retry button. Clients should ignore part types they don't know, since new ones can be added without a new protocol version. - -When nothing has been sent for 10 seconds, during a slow tool for example, the server sends a `: ping` comment line. SSE clients ignore it, and it keeps proxies and the widget's inactivity timer from closing a healthy connection. - -A request that can't be answered gets a real HTTP status before any stream, with CORS headers and a JSON body: - -```json -{ "error": { "code": "rate_limited", "retryAfterMs": 42000 } } -``` - -| Status | Code | When | -|---|---|---| -| 400 | `invalid_request` | The body isn't valid. The message names the fields, never their values. | -| 401 | `session_required` | Session tokens are on and the request has no valid token. | -| 401 | `token_expired` | The session token has expired. Get a new one from `/session`. | -| 401 | `conversation_mismatch` | The session token is for a different conversation. | -| 401 | `turnstile_failed` | `/session` only: the Turnstile token is missing or didn't pass. | -| 409 | `conversation_busy` | A reply for this conversation is still being written. | -| 413 | `payload_too_large` | The body is over 256 KB. With Express, `express.json()` sets the limit (100 KB by default) and answers 413 itself. | -| 429 | `rate_limited` | Too many requests from this IP. `Retry-After` has the seconds, and `retryAfterMs` the milliseconds. | -| 500 | `internal_error` | An unexpected error in the server. | -| 503 | `provider_unavailable` | The provider couldn't be set up, such as a missing API key. The server logged why at startup. | - -`403` for a site that isn't in `allowedOrigins` and `405` for anything but `POST` keep their `{"error":"..."}` bodies, since they're answered before the body is read. `OPTIONS` requests are answered as CORS preflights. +For the full specification covering format negotiation, request fields, SSE framing, all 13 wire part variants, and HTTP error mappings, see the [Protocol Reference](/server/protocol/). ### The 0.5 format From 131099b1a4c0fff3679fbc41ba46f7f48b64ebf6 Mon Sep 17 00:00:00 2001 From: Bhushan Agrawal Date: Thu, 22 Oct 2026 20:53:42 +0530 Subject: [PATCH 73/80] feat(store-cloudflare): add Cloudflare storage for D1, KV and Durable Objects (#296) * feat(store-cloudflare): add Cloudflare storage for D1, KV and Durable Objects (#249) * fix(store-cloudflare): fix concurrency bottlenecks and increase vitest timeout * fix(store-cloudflare): address maintainer review feedback for PR #296 --------- Co-authored-by: Bhushan Agrawal --- .changeset/store-cloudflare.md | 5 + packages/store-cloudflare/README.md | 123 ++++++ packages/store-cloudflare/package.json | 58 +++ .../store-cloudflare/src/conversations.ts | 317 +++++++++++++++ packages/store-cloudflare/src/counters.ts | 111 +++++ packages/store-cloudflare/src/events.ts | 50 +++ packages/store-cloudflare/src/index.ts | 59 +++ packages/store-cloudflare/src/kv.ts | 88 ++++ packages/store-cloudflare/src/schema.ts | 44 ++ packages/store-cloudflare/src/types.ts | 93 +++++ packages/store-cloudflare/src/workers.d.ts | 7 + .../store-cloudflare/tests/cloudflare.test.ts | 241 +++++++++++ .../tests/conformance.test.ts | 25 ++ .../tests/durable-object.test.ts | 55 +++ packages/store-cloudflare/tests/miniflare.ts | 60 +++ packages/store-cloudflare/tsconfig.json | 9 + packages/store-cloudflare/tsconfig.test.json | 9 + packages/store-cloudflare/vitest.config.ts | 9 + pnpm-lock.yaml | 380 ++++++++++++++++++ 19 files changed, 1743 insertions(+) create mode 100644 .changeset/store-cloudflare.md create mode 100644 packages/store-cloudflare/README.md create mode 100644 packages/store-cloudflare/package.json create mode 100644 packages/store-cloudflare/src/conversations.ts create mode 100644 packages/store-cloudflare/src/counters.ts create mode 100644 packages/store-cloudflare/src/events.ts create mode 100644 packages/store-cloudflare/src/index.ts create mode 100644 packages/store-cloudflare/src/kv.ts create mode 100644 packages/store-cloudflare/src/schema.ts create mode 100644 packages/store-cloudflare/src/types.ts create mode 100644 packages/store-cloudflare/src/workers.d.ts create mode 100644 packages/store-cloudflare/tests/cloudflare.test.ts create mode 100644 packages/store-cloudflare/tests/conformance.test.ts create mode 100644 packages/store-cloudflare/tests/durable-object.test.ts create mode 100644 packages/store-cloudflare/tests/miniflare.ts create mode 100644 packages/store-cloudflare/tsconfig.json create mode 100644 packages/store-cloudflare/tsconfig.test.json create mode 100644 packages/store-cloudflare/vitest.config.ts diff --git a/.changeset/store-cloudflare.md b/.changeset/store-cloudflare.md new file mode 100644 index 00000000..dba41ba5 --- /dev/null +++ b/.changeset/store-cloudflare.md @@ -0,0 +1,5 @@ +--- +"@chatcops/store-cloudflare": minor +--- + +Add Cloudflare storage for D1, KV, and Durable Objects counters. diff --git a/packages/store-cloudflare/README.md b/packages/store-cloudflare/README.md new file mode 100644 index 00000000..009c8d45 --- /dev/null +++ b/packages/store-cloudflare/README.md @@ -0,0 +1,123 @@ +# @chatcops/store-cloudflare + +Cloudflare storage for [ChatCops](https://chat.codercops.com): conversations, exact atomic counters, turn locks, and analytics events backed by Cloudflare D1, Workers KV, and Durable Objects. + +## Features + +- **Conversations & Messages:** Durable history in Cloudflare D1 SQLite with concurrency-safe `append` without interactive transactions. +- **Exact Counters:** Globally coordinated rate limits and token budgets through Durable Objects (`ChatCopsCounter`), with automatic fallback to D1 `UPSERT ... RETURNING`. +- **Key-Value Store:** Ephemeral key-value caching using Workers KV or D1 SQLite fallback. Turn locks require the D1 fallback because Workers KV does not support atomic `setIfAbsent`; Workers KV also enforces a 60-second minimum TTL. +- **Analytics Events:** Time-bucketed event queries in D1 for dashboards and reporting. + +## Install + +```bash +npm install @chatcops/store-cloudflare +``` + +## Wrangler Configuration + +Configure your D1 database, Durable Object class binding, and migration in `wrangler.jsonc`: + +```jsonc +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "my-chatcops-worker", + "main": "src/index.ts", + "compatibility_date": "2026-10-01", + "compatibility_flags": ["nodejs_compat"], + "d1_databases": [ + { + "binding": "DB", + "database_name": "chatcops", + "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "COUNTERS", + "class_name": "ChatCopsCounter" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ChatCopsCounter"] + } + ], + "kv_namespaces": [ + { + "binding": "KV", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ] +} +``` + +## Usage + +Export `ChatCopsCounter` from your Worker's entrypoint so Cloudflare can route Durable Object requests to it: + +```ts +import { cloudflareStorage, ChatCopsCounter } from '@chatcops/store-cloudflare'; +import { chatcopsCloudflareHandler } from '@chatcops/server/cloudflare'; + +// Re-export the Durable Object class for Cloudflare Workers +export { ChatCopsCounter }; + +interface Env { + DB: D1Database; + KV?: KVNamespace; + COUNTERS?: DurableObjectNamespace; + ANTHROPIC_API_KEY: string; +} + +let handler: ReturnType | undefined; + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + handler ??= chatcopsCloudflareHandler({ + provider: { type: 'claude', apiKey: env.ANTHROPIC_API_KEY }, + systemPrompt: 'You are a helpful assistant.', + storage: cloudflareStorage({ + d1: env.DB, + kv: env.KV, + counters: env.COUNTERS, + }), + }); + + return handler(request, env, ctx); + }, +}; +``` + +## Options + +| Option | Type | Required | Description | +|---|---|---|---| +| `d1` | `D1Database` | **Yes** | Cloudflare D1 database binding used for conversations, messages, events, turn locks, and fallback storage. | +| `kv` | `KVNamespace` | No | Cloudflare Workers KV namespace for fast key-value storage (minimum TTL: 60 seconds). Turn locks require the D1 fallback because Workers KV does not support atomic `setIfAbsent`. When omitted, D1 is used as fallback. | +| `counters` | `DurableObjectNamespace` | No | Durable Objects namespace for exact distributed counter coordination. When omitted, D1 atomic upserts are used. | +| `now` | `() => number` | No | Clock function for testing (defaults to `Date.now`). | + +## Concurrency & Durability + +Cloudflare D1 does not provide interactive transactions. To ensure data integrity under concurrent traffic: + +- `append` executes inside a single `db.batch()` statement list. +- An expected-version check conditional update runs first, bumping the version and setting `updated_at`. +- All subsequent message inserts are conditioned on the conversation having that new version and timestamp. +- If concurrent requests race with the same expected version, only one succeeds and the others throw `ConflictError` without creating partial records. +- When no expected version is passed, `append` automatically retries against the latest version. + +## Retention & Cleanup + +To prune old conversations and expired metadata on a schedule (e.g., via a Cron Trigger): + +```ts +// Delete conversations older than 30 days +const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; +const purgedCount = await storage.conversations.purge?.(cutoff); +``` diff --git a/packages/store-cloudflare/package.json b/packages/store-cloudflare/package.json new file mode 100644 index 00000000..8ebb3bbf --- /dev/null +++ b/packages/store-cloudflare/package.json @@ -0,0 +1,58 @@ +{ + "name": "@chatcops/store-cloudflare", + "version": "0.0.0", + "private": true, + "description": "Cloudflare D1, KV and Durable Objects storage for ChatCops", + "keywords": [ + "chatcops", + "storage", + "cloudflare", + "d1", + "workers-kv", + "durable-objects" + ], + "homepage": "https://chat.codercops.com", + "bugs": "https://github.com/codercops/chatcops/issues", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "sideEffects": false, + "scripts": { + "build": "tsc", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "peerDependencies": { + "@chatcops/core": "workspace:^" + }, + "devDependencies": { + "@chatcops/core": "workspace:*", + "@types/node": "^22", + "miniflare": "^4", + "typescript": "^5.7.0", + "vitest": "^4.1.11" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codercops/chatcops.git", + "directory": "packages/store-cloudflare" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "engines": { + "node": ">=22" + } +} diff --git a/packages/store-cloudflare/src/conversations.ts b/packages/store-cloudflare/src/conversations.ts new file mode 100644 index 00000000..779c716b --- /dev/null +++ b/packages/store-cloudflare/src/conversations.ts @@ -0,0 +1,317 @@ +import { + ConflictError, + type AppendOptions, + type ChatMessage, + type Conversation, + type ConversationStore, + type ConversationSummary, + type ListQuery, +} from '@chatcops/core'; +import type { D1Database, D1PreparedStatement } from './types.js'; + +interface ConversationRow { + id: string; + metadata: string | null; + version: number; + created_at: number; + updated_at: number; +} + +interface MessageRow { + message: string; +} + +const json = (value: unknown): string | null => (value === undefined ? null : JSON.stringify(value)); + +export class CloudflareConversations implements ConversationStore { + constructor( + private readonly db: D1Database, + private readonly now: () => number, + private readonly ensure: () => Promise + ) {} + + async get(id: string): Promise { + await this.ensure(); + const row = await this.db + .prepare('SELECT * FROM chatcops_conversations WHERE id = ?') + .bind(id) + .first(); + if (!row) return null; + + const { results } = await this.db + .prepare('SELECT message FROM chatcops_messages WHERE conversation_id = ? ORDER BY seq') + .bind(id) + .all(); + + const messages = results.map((m) => JSON.parse(m.message) as ChatMessage); + return { + id: row.id, + messages, + ...(row.metadata ? { metadata: JSON.parse(row.metadata) } : {}), + version: row.version, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + async save(conversation: Conversation): Promise { + await this.ensure(); + const time = this.now(); + + const stmts: D1PreparedStatement[] = [ + this.db + .prepare(` + INSERT INTO chatcops_conversations (id, metadata, version, created_at, updated_at) + VALUES (?, ?, 1, ?, ?) + ON CONFLICT(id) DO UPDATE SET + metadata = excluded.metadata, + version = chatcops_conversations.version + 1, + updated_at = excluded.updated_at + `) + .bind( + conversation.id, + json(conversation.metadata), + conversation.createdAt, + time + ), + this.db.prepare('DELETE FROM chatcops_messages WHERE conversation_id = ?').bind(conversation.id), + ]; + + for (const [index, message] of conversation.messages.entries()) { + stmts.push( + this.db + .prepare('INSERT INTO chatcops_messages (conversation_id, seq, id, message) VALUES (?, ?, ?, ?)') + .bind(conversation.id, index + 1, message.id, JSON.stringify(message)) + ); + } + + await this.db.batch(stmts); + } + + async delete(id: string): Promise { + await this.ensure(); + await this.db.batch([ + this.db.prepare('DELETE FROM chatcops_messages WHERE conversation_id = ?').bind(id), + this.db.prepare('DELETE FROM chatcops_conversations WHERE id = ?').bind(id), + ]); + } + + async append( + id: string, + messages: ChatMessage[], + options: AppendOptions = {} + ): Promise<{ version: number }> { + await this.ensure(); + const maxAttempts = 50; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 5 + Math.random() * 15)); + } + const current = await this.db + .prepare(` + SELECT version, metadata, + (SELECT COALESCE(MAX(seq), 0) FROM chatcops_messages WHERE conversation_id = ?) AS max_seq + FROM chatcops_conversations WHERE id = ? + `) + .bind(id, id) + .first<{ version: number; metadata: string | null; max_seq: number }>(); + + const actual = current?.version ?? 0; + if (options.expectedVersion !== undefined && options.expectedVersion !== actual) { + throw new ConflictError(id, options.expectedVersion, actual); + } + + const time = this.now(); + const metadata = + options.metadata || current?.metadata + ? { ...(current?.metadata ? JSON.parse(current.metadata) : {}), ...(options.metadata ?? {}) } + : undefined; + + const nextVersion = actual + 1; + const lastSeq = Number(current?.max_seq ?? 0); + + const batch: D1PreparedStatement[] = []; + + if (!current) { + batch.push( + this.db + .prepare(` + INSERT INTO chatcops_conversations (id, metadata, version, created_at, updated_at) + SELECT ?, ?, 1, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM chatcops_conversations WHERE id = ?) + `) + .bind(id, json(metadata), time, time, id) + ); + } else { + batch.push( + this.db + .prepare(` + UPDATE chatcops_conversations + SET metadata = ?, version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `) + .bind(json(metadata), time, id, actual) + ); + } + + for (const [index, msg] of messages.entries()) { + batch.push( + this.db + .prepare(` + INSERT INTO chatcops_messages (conversation_id, seq, id, message) + SELECT ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM chatcops_conversations + WHERE id = ? AND version = ? AND updated_at = ? + ) + `) + .bind( + id, + lastSeq + index + 1, + msg.id, + JSON.stringify(msg), + id, + nextVersion, + time + ) + ); + } + + try { + const results = await this.db.batch(batch); + const changes = results[0]?.meta?.changes ?? 0; + + if (changes === 0) { + if (options.expectedVersion !== undefined) { + const fresh = await this.db + .prepare('SELECT version FROM chatcops_conversations WHERE id = ?') + .bind(id) + .first<{ version: number }>(); + const freshVersion = fresh?.version ?? 0; + throw new ConflictError(id, options.expectedVersion, freshVersion); + } + continue; + } + + return { version: nextVersion }; + } catch (err) { + if (err instanceof ConflictError) throw err; + const isUniqueConstraint = + err instanceof Error && /UNIQUE constraint failed/i.test(err.message); + if (!isUniqueConstraint) { + throw err; + } + if (options.expectedVersion !== undefined) { + const fresh = await this.db + .prepare('SELECT version FROM chatcops_conversations WHERE id = ?') + .bind(id) + .first<{ version: number }>(); + const freshVersion = fresh?.version ?? 0; + throw new ConflictError(id, options.expectedVersion, freshVersion); + } + } + } + + throw new Error(`Failed to append to conversation ${id} after ${maxAttempts} attempts due to concurrency conflicts.`); + } + + async updateMessage(id: string, message: ChatMessage): Promise { + await this.ensure(); + const time = this.now(); + const results = await this.db.batch([ + this.db + .prepare('UPDATE chatcops_messages SET message = ? WHERE conversation_id = ? AND id = ?') + .bind(JSON.stringify(message), id, message.id), + this.db + .prepare(` + UPDATE chatcops_conversations + SET version = version + 1, updated_at = ? + WHERE id = ? AND EXISTS (SELECT 1 FROM chatcops_messages WHERE conversation_id = ? AND id = ?) + `) + .bind(time, id, id, message.id), + ]); + const changes = results[0]?.meta?.changes ?? 0; + return changes > 0; + } + + async removeMessage(id: string, messageId: string): Promise { + await this.ensure(); + const time = this.now(); + const results = await this.db.batch([ + this.db + .prepare(` + UPDATE chatcops_conversations + SET version = version + 1, updated_at = ? + WHERE id = ? AND EXISTS (SELECT 1 FROM chatcops_messages WHERE conversation_id = ? AND id = ?) + `) + .bind(time, id, id, messageId), + this.db + .prepare('DELETE FROM chatcops_messages WHERE conversation_id = ? AND id = ?') + .bind(id, messageId), + ]); + const changes = results[1]?.meta?.changes ?? 0; + return changes > 0; + } + + async list(query: ListQuery): Promise<{ items: ConversationSummary[]; cursor?: string }> { + await this.ensure(); + let cursorTime: number | null = null; + let cursorId: string | null = null; + + if (query.cursor) { + const separator = query.cursor.indexOf(':'); + cursorTime = separator === -1 ? null : Number(query.cursor.slice(0, separator)); + cursorId = separator === -1 ? null : query.cursor.slice(separator + 1); + if (separator === -1 || !Number.isFinite(cursorTime)) { + throw new Error(`Not a list cursor: "${query.cursor}".`); + } + } + + const { results } = await this.db + .prepare(` + SELECT c.*, COUNT(m.seq) AS message_count + FROM chatcops_conversations c + LEFT JOIN chatcops_messages m ON m.conversation_id = c.id + WHERE (? IS NULL OR c.updated_at > ?) + AND (? IS NULL OR c.updated_at < ? OR (c.updated_at = ? AND c.id > ?)) + GROUP BY c.id + ORDER BY c.updated_at DESC, c.id ASC + LIMIT ? + `) + .bind( + query.updatedAfter ?? null, + query.updatedAfter ?? null, + cursorTime, + cursorTime, + cursorTime, + cursorId, + query.limit + 1 + ) + .all(); + + const page = results.slice(0, query.limit); + const items: ConversationSummary[] = page.map((row) => ({ + id: row.id, + createdAt: row.created_at, + updatedAt: row.updated_at, + messageCount: Number(row.message_count), + ...(row.metadata ? { metadata: JSON.parse(row.metadata) } : {}), + })); + + const last = page.at(-1); + return results.length > query.limit && last + ? { items, cursor: `${last.updated_at}:${last.id}` } + : { items }; + } + + async purge(before: number): Promise { + await this.ensure(); + const result = await this.db + .prepare('DELETE FROM chatcops_conversations WHERE updated_at < ?') + .bind(before) + .run(); + + return result.meta?.changes ?? 0; + } +} diff --git a/packages/store-cloudflare/src/counters.ts b/packages/store-cloudflare/src/counters.ts new file mode 100644 index 00000000..cacc1d51 --- /dev/null +++ b/packages/store-cloudflare/src/counters.ts @@ -0,0 +1,111 @@ +import type { CounterStore, CounterValue } from '@chatcops/core'; +import type { D1Database, DurableObjectNamespace, DurableObjectState } from './types.js'; + +const CloudflareWorkers = + typeof navigator !== 'undefined' && (navigator as { userAgent?: string }).userAgent === 'Cloudflare-Workers' + ? await import('cloudflare:workers') + : null; + +const BaseDurableObject = + CloudflareWorkers?.DurableObject ?? + (class { + ctx: DurableObjectState; + env: unknown; + constructor(ctx: DurableObjectState, env: unknown) { + this.ctx = ctx; + this.env = env; + } + } as unknown as typeof import('cloudflare:workers').DurableObject); + +interface StoredCounter { + value: number; + expiresAt: number; +} + +export class ChatCopsCounter extends BaseDurableObject { + declare ctx: DurableObjectState; + declare env: unknown; + + async increment(by: number, ttlMs: number): Promise { + const key = 'counter'; + const now = Date.now(); + const current = (await this.ctx.storage.get(key)) as StoredCounter | undefined; + let next: StoredCounter; + if (!current || current.expiresAt <= now) { + next = { value: by, expiresAt: now + ttlMs }; + await this.ctx.storage.setAlarm(next.expiresAt); + } else { + next = { value: current.value + by, expiresAt: current.expiresAt }; + } + await this.ctx.storage.put(key, next); + return next; + } + + async get(): Promise { + const key = 'counter'; + const current = (await this.ctx.storage.get(key)) as StoredCounter | undefined; + if (!current || current.expiresAt <= Date.now()) { + return null; + } + return current; + } + + async alarm(): Promise { + await this.ctx.storage.deleteAll(); + } +} + +export class DoCounters implements CounterStore { + constructor(private readonly namespace: DurableObjectNamespace) {} + + async increment(key: string, by: number, ttlMs: number): Promise { + const id = this.namespace.idFromName(key); + const stub = this.namespace.get(id); + return await stub.increment(by, ttlMs); + } + + async get(key: string): Promise { + const id = this.namespace.idFromName(key); + const stub = this.namespace.get(id); + return (await stub.get()) ?? null; + } +} + +export class D1Counters implements CounterStore { + constructor( + private readonly db: D1Database, + private readonly now: () => number, + private readonly ensure: () => Promise + ) {} + + async increment(key: string, by: number, ttlMs: number): Promise { + await this.ensure(); + const time = this.now(); + const row = await this.db + .prepare(` + INSERT INTO chatcops_counters (key, value, expires_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = CASE WHEN chatcops_counters.expires_at <= ? + THEN excluded.value ELSE chatcops_counters.value + excluded.value END, + expires_at = CASE WHEN chatcops_counters.expires_at <= ? + THEN excluded.expires_at ELSE chatcops_counters.expires_at END + RETURNING value, expires_at + `) + .bind(key, by, time + ttlMs, time, time) + .first<{ value: number; expires_at: number }>(); + + if (!row) throw new Error('D1 counter upsert did not return a row'); + return { value: Number(row.value), expiresAt: Number(row.expires_at) }; + } + + async get(key: string): Promise { + await this.ensure(); + const row = await this.db + .prepare('SELECT value, expires_at FROM chatcops_counters WHERE key = ? AND expires_at > ?') + .bind(key, this.now()) + .first<{ value: number; expires_at: number }>(); + + return row ? { value: Number(row.value), expiresAt: Number(row.expires_at) } : null; + } +} diff --git a/packages/store-cloudflare/src/events.ts b/packages/store-cloudflare/src/events.ts new file mode 100644 index 00000000..b7a4b840 --- /dev/null +++ b/packages/store-cloudflare/src/events.ts @@ -0,0 +1,50 @@ +import type { AnalyticsEvent, EventCount, EventQuery, EventSink } from '@chatcops/core'; +import type { D1Database } from './types.js'; + +const json = (value: unknown): string | null => (value === undefined ? null : JSON.stringify(value)); + +export class CloudflareEvents implements EventSink { + constructor( + private readonly db: D1Database, + private readonly ensure: () => Promise + ) {} + + async write(events: AnalyticsEvent[]): Promise { + if (events.length === 0) return; + await this.ensure(); + const stmts = events.map((event) => + this.db + .prepare('INSERT INTO chatcops_events (type, data, created_at) VALUES (?, ?, ?)') + .bind(event.type, json(event.data), event.timestamp) + ); + await this.db.batch(stmts); + } + + async query(query: EventQuery): Promise { + await this.ensure(); + const size = query.groupBy === 'hour' ? 3_600_000 : 86_400_000; + const { results } = await this.db + .prepare(` + SELECT CAST((created_at / ?) AS INTEGER) * ? AS bucket, type, COUNT(*) AS count + FROM chatcops_events + WHERE created_at >= ? AND created_at < ? AND (? IS NULL OR type = ?) + GROUP BY bucket, type + ORDER BY bucket, type + `) + .bind( + size, + size, + query.from, + query.to, + query.type ?? null, + query.type ?? null + ) + .all<{ bucket: number; type: string; count: number }>(); + + return results.map((row) => ({ + bucket: Number(row.bucket), + type: row.type, + count: Number(row.count), + })); + } +} diff --git a/packages/store-cloudflare/src/index.ts b/packages/store-cloudflare/src/index.ts new file mode 100644 index 00000000..ab1ed2ac --- /dev/null +++ b/packages/store-cloudflare/src/index.ts @@ -0,0 +1,59 @@ +import type { ChatCopsStorage } from '@chatcops/core'; +import { CloudflareConversations } from './conversations.js'; +import { type ChatCopsCounter, D1Counters, DoCounters } from './counters.js'; +import { CloudflareEvents } from './events.js'; +import { D1KvStore, WorkersKvStore } from './kv.js'; +import { ensureSchema } from './schema.js'; +import type { D1Database, DurableObjectNamespace, KVNamespace } from './types.js'; + +export { ChatCopsCounter } from './counters.js'; +export { SCHEMA_SQL, SCHEMA_VERSION, initializeSchema } from './schema.js'; +export type { + D1Database, + D1ExecResult, + D1PreparedStatement, + D1Result, + DurableObjectId, + DurableObjectNamespace, + DurableObjectState, + DurableObjectStorage, + DurableObjectStub, + KVNamespace, +} from './types.js'; + +export interface CloudflareStorageOptions { + /** The D1 database binding. Required. */ + d1: D1Database; + /** Optional Workers KV namespace for key-value data. */ + kv?: KVNamespace; + /** Optional Durable Object namespace for exact distributed counters. */ + counters?: DurableObjectNamespace; + /** Optional custom clock for testing. */ + now?: () => number; +} + +/** + * Cloudflare storage for ChatCops: conversations, rate limit counters, + * turn locks, and analytics events backed by Cloudflare D1, Workers KV, + * and Durable Objects. + */ +export function cloudflareStorage(options: CloudflareStorageOptions): ChatCopsStorage { + if (!options?.d1) { + throw new Error('cloudflareStorage requires a D1 database'); + } + + const now = options.now ?? Date.now; + const ensure = () => ensureSchema(options.d1); + + return { + conversations: new CloudflareConversations(options.d1, now, ensure), + counters: options.counters + ? new DoCounters(options.counters) + : new D1Counters(options.d1, now, ensure), + kv: options.kv + ? new WorkersKvStore(options.kv) + : new D1KvStore(options.d1, now, ensure), + events: new CloudflareEvents(options.d1, ensure), + close: async () => {}, + }; +} diff --git a/packages/store-cloudflare/src/kv.ts b/packages/store-cloudflare/src/kv.ts new file mode 100644 index 00000000..e50c858c --- /dev/null +++ b/packages/store-cloudflare/src/kv.ts @@ -0,0 +1,88 @@ +import type { KeyValueStore } from '@chatcops/core'; +import type { D1Database, KVNamespace } from './types.js'; + +export class WorkersKvStore implements KeyValueStore { + constructor(private readonly kv: KVNamespace) {} + + async get(key: string): Promise { + const text = await this.kv.get(key, 'text'); + if (typeof text !== 'string') return null; + return JSON.parse(text) as T; + } + + async set(key: string, value: unknown, options: { ttlMs?: number } = {}): Promise { + const expirationTtl = + options.ttlMs !== undefined + ? Math.max(60, Math.ceil(options.ttlMs / 1000)) + : undefined; + await this.kv.put(key, JSON.stringify(value), expirationTtl ? { expirationTtl } : undefined); + } + + async delete(key: string): Promise { + await this.kv.delete(key); + } +} + +export class D1KvStore implements KeyValueStore { + constructor( + private readonly db: D1Database, + private readonly now: () => number, + private readonly ensure: () => Promise + ) {} + + async get(key: string): Promise { + await this.ensure(); + const row = await this.db + .prepare('SELECT value FROM chatcops_kv WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)') + .bind(key, this.now()) + .first<{ value: string }>(); + + return row ? (JSON.parse(row.value) as T) : null; + } + + async set(key: string, value: unknown, options: { ttlMs?: number } = {}): Promise { + await this.ensure(); + await this.db + .prepare(` + INSERT INTO chatcops_kv (key, value, expires_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + expires_at = excluded.expires_at + `) + .bind( + key, + JSON.stringify(value), + options.ttlMs === undefined ? null : this.now() + options.ttlMs + ) + .run(); + } + + async setIfAbsent(key: string, value: unknown, options: { ttlMs?: number } = {}): Promise { + await this.ensure(); + const time = this.now(); + const result = await this.db + .prepare(` + INSERT INTO chatcops_kv (key, value, expires_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + expires_at = excluded.expires_at + WHERE chatcops_kv.expires_at IS NOT NULL AND chatcops_kv.expires_at <= ? + `) + .bind( + key, + JSON.stringify(value), + options.ttlMs === undefined ? null : time + options.ttlMs, + time + ) + .run(); + + return (result.meta?.changes ?? 0) > 0; + } + + async delete(key: string): Promise { + await this.ensure(); + await this.db.prepare('DELETE FROM chatcops_kv WHERE key = ?').bind(key).run(); + } +} diff --git a/packages/store-cloudflare/src/schema.ts b/packages/store-cloudflare/src/schema.ts new file mode 100644 index 00000000..ffc72f3d --- /dev/null +++ b/packages/store-cloudflare/src/schema.ts @@ -0,0 +1,44 @@ +import type { D1Database } from './types.js'; + +export const SCHEMA_VERSION = 1; + +export const SCHEMA_STATEMENTS = [ + 'CREATE TABLE IF NOT EXISTS chatcops_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)', + 'CREATE TABLE IF NOT EXISTS chatcops_conversations (id TEXT PRIMARY KEY, metadata TEXT, version INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)', + 'CREATE TABLE IF NOT EXISTS chatcops_messages (conversation_id TEXT NOT NULL, seq INTEGER NOT NULL, id TEXT NOT NULL, message TEXT NOT NULL, PRIMARY KEY (conversation_id, seq), UNIQUE (conversation_id, id), FOREIGN KEY (conversation_id) REFERENCES chatcops_conversations(id) ON DELETE CASCADE)', + 'CREATE TABLE IF NOT EXISTS chatcops_counters (key TEXT PRIMARY KEY, value REAL NOT NULL, expires_at INTEGER NOT NULL)', + 'CREATE TABLE IF NOT EXISTS chatcops_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)', + 'CREATE TABLE IF NOT EXISTS chatcops_events (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, data TEXT, created_at INTEGER NOT NULL)', + 'CREATE INDEX IF NOT EXISTS chatcops_conversations_updated_idx ON chatcops_conversations(updated_at DESC, id)', + 'CREATE INDEX IF NOT EXISTS chatcops_events_created_idx ON chatcops_events(created_at)', + 'CREATE INDEX IF NOT EXISTS chatcops_events_type_created_idx ON chatcops_events(type, created_at)', +]; + +export const SCHEMA_SQL = SCHEMA_STATEMENTS.map((s) => `${s};`).join('\n'); + +export async function initializeSchema(db: D1Database): Promise { + await db.batch(SCHEMA_STATEMENTS.map((sql) => db.prepare(sql))); + const row = await db + .prepare('SELECT value FROM chatcops_meta WHERE key = ?') + .bind('schema_version') + .first<{ value?: string }>(); + if (!row) { + await db + .prepare('INSERT INTO chatcops_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING') + .bind('schema_version', String(SCHEMA_VERSION)) + .run(); + } else if (Number(row.value) > SCHEMA_VERSION) { + throw new Error(`Unsupported ChatCops SQLite schema version ${row.value}`); + } +} + +const initializingDatabases = new WeakMap>(); + +export async function ensureSchema(db: D1Database): Promise { + let promise = initializingDatabases.get(db); + if (!promise) { + promise = initializeSchema(db); + initializingDatabases.set(db, promise); + } + return promise; +} diff --git a/packages/store-cloudflare/src/types.ts b/packages/store-cloudflare/src/types.ts new file mode 100644 index 00000000..cfccee7c --- /dev/null +++ b/packages/store-cloudflare/src/types.ts @@ -0,0 +1,93 @@ +export interface D1Database { + prepare(query: string): D1PreparedStatement; + batch(statements: D1PreparedStatement[]): Promise[]>; + exec(query: string): Promise; +} + +export interface D1PreparedStatement { + bind(...values: unknown[]): D1PreparedStatement; + first(colName?: string): Promise; + run(): Promise>; + all(): Promise>; + raw(): Promise; +} + +export interface D1Result { + results: T[]; + success: boolean; + meta?: { + duration?: number; + size_after?: number; + rows_read?: number; + rows_written?: number; + last_row_id?: number; + changes?: number; + served_by?: string; + [key: string]: unknown; + }; + error?: string; +} + +export interface D1ExecResult { + count: number; + duration: number; +} + +export interface KVNamespace { + get(key: string, type?: 'text' | 'json' | 'arrayBuffer' | 'stream'): Promise; + put( + key: string, + value: string | ArrayBuffer | ArrayBufferView | ReadableStream, + options?: { + expiration?: number; + expirationTtl?: number; + metadata?: unknown; + } + ): Promise; + delete(key: string): Promise; +} + +export interface DurableObjectId { + toString(): string; + equals(other: DurableObjectId): boolean; + name?: string; +} + +export type DurableObjectStub = { + id: DurableObjectId; + name?: string; +} & T; + +export interface DurableObjectNamespace { + newUniqueId(options?: { jurisdiction?: string }): DurableObjectId; + idFromName(name: string): DurableObjectId; + idFromString(id: string): DurableObjectId; + get(id: DurableObjectId): DurableObjectStub; +} + +export interface DurableObjectState { + id: DurableObjectId; + storage: DurableObjectStorage; + waitUntil(promise: Promise): void; +} + +export interface DurableObjectStorage { + get(key: string): Promise; + get(keys: string[]): Promise>; + put(key: string, value: T): Promise; + put(entries: Record): Promise; + delete(key: string): Promise; + delete(keys: string[]): Promise; + deleteAll(): Promise; + list(options?: { + start?: string; + startAfter?: string; + end?: string; + prefix?: string; + reverse?: boolean; + limit?: number; + }): Promise>; + setAlarm(scheduledTime: number | Date, options?: unknown): Promise; + getAlarm?(options?: unknown): Promise; + deleteAlarm?(options?: unknown): Promise; +} diff --git a/packages/store-cloudflare/src/workers.d.ts b/packages/store-cloudflare/src/workers.d.ts new file mode 100644 index 00000000..971a0587 --- /dev/null +++ b/packages/store-cloudflare/src/workers.d.ts @@ -0,0 +1,7 @@ +declare module 'cloudflare:workers' { + export abstract class DurableObject { + ctx: import('./types.js').DurableObjectState; + env: unknown; + constructor(ctx: import('./types.js').DurableObjectState, env: unknown); + } +} diff --git a/packages/store-cloudflare/tests/cloudflare.test.ts b/packages/store-cloudflare/tests/cloudflare.test.ts new file mode 100644 index 00000000..beef8461 --- /dev/null +++ b/packages/store-cloudflare/tests/cloudflare.test.ts @@ -0,0 +1,241 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { CloudflareConversations } from '../src/conversations.js'; +import { + ChatCopsCounter, + SCHEMA_VERSION, + cloudflareStorage, + initializeSchema, +} from '../src/index.js'; +import type { D1Database, DurableObjectState } from '../src/types.js'; +import { disposeMiniflare, getMiniflareBindings } from './miniflare.js'; + +describe('cloudflare storage options and edge cases', () => { + it('throws when d1 is not provided', () => { + expect(() => cloudflareStorage({} as unknown as Parameters[0])).toThrow( + 'cloudflareStorage requires a D1 database' + ); + }); + + it('throws on unsupported schema version', async () => { + const { d1 } = await getMiniflareBindings(); + await initializeSchema(d1); + + await d1 + .prepare('INSERT OR REPLACE INTO chatcops_meta (key, value) VALUES (?, ?)') + .bind('schema_version', String(SCHEMA_VERSION + 1)) + .run(); + + await expect(initializeSchema(d1)).rejects.toThrow('Unsupported ChatCops SQLite schema version'); + + // restore version + await d1 + .prepare('INSERT OR REPLACE INTO chatcops_meta (key, value) VALUES (?, ?)') + .bind('schema_version', String(SCHEMA_VERSION)) + .run(); + }); + + it('throws on invalid list cursor', async () => { + const { d1 } = await getMiniflareBindings(); + const storage = cloudflareStorage({ d1 }); + await expect(storage.conversations.list?.({ limit: 10, cursor: 'invalid' })).rejects.toThrow( + 'Not a list cursor' + ); + }); + + it('handles empty events array without errors', async () => { + const { d1 } = await getMiniflareBindings(); + const storage = cloudflareStorage({ d1 }); + await expect(storage.events?.write([])).resolves.toBeUndefined(); + }); + + it('queries events by hour and with type filter', async () => { + const { d1 } = await getMiniflareBindings(); + const storage = cloudflareStorage({ d1 }); + const now = Date.now(); + await storage.events?.write([ + { type: 'click', timestamp: now }, + { type: 'view', timestamp: now + 10 }, + { type: 'click', timestamp: now + 3600 * 1000 }, + ]); + + const byHourWithType = await storage.events?.query?.({ + from: now - 1000, + to: now + 7200 * 1000, + type: 'click', + groupBy: 'hour', + }); + expect(byHourWithType?.length).toBeGreaterThanOrEqual(1); + + const byDayAll = await storage.events?.query?.({ + from: now - 1000, + to: now + 7200 * 1000, + groupBy: 'day', + }); + expect(byDayAll?.length).toBeGreaterThanOrEqual(1); + }); + + it('calls close without error', async () => { + const { d1 } = await getMiniflareBindings(); + const storage = cloudflareStorage({ d1 }); + await expect(storage.close?.()).resolves.toBeUndefined(); + }); + + it('supports KV set without ttl and KV delete', async () => { + const { kv } = await getMiniflareBindings(); + const storage = cloudflareStorage({ d1: (await getMiniflareBindings()).d1, kv }); + await storage.kv?.set('no-ttl', { foo: 'bar' }); + expect(await storage.kv?.get('no-ttl')).toEqual({ foo: 'bar' }); + await storage.kv?.delete('no-ttl'); + expect(await storage.kv?.get('no-ttl')).toBeNull(); + }); + + it('supports ChatCopsCounter direct unit calls with mock storage', async () => { + const store = new Map(); + let scheduledAlarm: number | undefined; + const mockState = { + id: { toString: () => 'id1', equals: () => true }, + storage: { + get: async (key: string) => store.get(key), + put: async (key: string, val: unknown) => store.set(key, val), + setAlarm: async (time: number | Date) => { + scheduledAlarm = typeof time === 'number' ? time : time.getTime(); + }, + deleteAll: async () => store.clear(), + delete: async () => true, + list: async () => store, + }, + waitUntil: () => {}, + }; + + const counter = new ChatCopsCounter(mockState as unknown as DurableObjectState, {}); + + expect(await counter.get()).toBeNull(); + + const first = await counter.increment(1, 60_000); + expect(first.value).toBe(1); + expect(scheduledAlarm).toBe(first.expiresAt); + + const second = await counter.increment(3, 60_000); + expect(second.value).toBe(4); + expect(await counter.get()).toEqual(second); + + // Alarm execution cleans storage + await counter.alarm(); + expect(store.size).toBe(0); + expect(await counter.get()).toBeNull(); + + // Expired counter reset creates a new alarm + const reset = await counter.increment(2, 60_000); + expect(reset.value).toBe(2); + expect(scheduledAlarm).toBe(reset.expiresAt); + }); + + it('propagates non-unique-constraint D1 errors from batch during append', async () => { + const d1Error = new Error('D1_ERROR: disk I/O failure'); + const fakeD1: D1Database = { + prepare: () => ({ + bind: () => fakeD1.prepare(''), + first: async () => null, + run: async () => ({ results: [], success: true }), + all: async () => ({ results: [], success: true }), + raw: async () => [], + }), + batch: async () => { + throw d1Error; + }, + exec: async () => ({ count: 0, duration: 0 }), + }; + + const convs = new CloudflareConversations(fakeD1, Date.now, async () => {}); + + const now = Date.now(); + await expect( + convs.append('conv-fake-err', [{ id: 'm1', role: 'user', content: 'test', timestamp: now }], { + expectedVersion: 0, + }) + ).rejects.toThrow('D1_ERROR: disk I/O failure'); + + await expect( + convs.append('conv-fake-err', [{ id: 'm1', role: 'user', content: 'test', timestamp: now }]) + ).rejects.toThrow('D1_ERROR: disk I/O failure'); + }); + + it('increments version atomically on save without extra select', async () => { + const { d1 } = await getMiniflareBindings(); + const storage = cloudflareStorage({ d1 }); + const id = `atomic-save-${Date.now()}`; + const now = Date.now(); + + await storage.conversations.save({ + id, + messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: now }], + createdAt: now, + updatedAt: now, + }); + + const first = await storage.conversations.get(id); + expect(first?.version).toBe(1); + + await storage.conversations.save({ + id, + messages: [ + { id: 'm1', role: 'user', content: 'hello', timestamp: now }, + { id: 'm2', role: 'assistant', content: 'world', timestamp: now + 5 }, + ], + createdAt: now, + updatedAt: now + 10, + }); + + const second = await storage.conversations.get(id); + expect(second?.version).toBe(2); + expect(second?.messages).toHaveLength(2); + }); + + it('purges conversations and relies on cascade to remove messages', async () => { + const { d1 } = await getMiniflareBindings(); + const storage = cloudflareStorage({ d1 }); + const id = `purge-test-${Date.now()}`; + const now = Date.now(); + + await storage.conversations.save({ + id, + messages: [{ id: 'm1', role: 'user', content: 'msg to be purged', timestamp: now }], + createdAt: now, + updatedAt: now, + }); + + const messagesBefore = await d1 + .prepare('SELECT count(*) as count FROM chatcops_messages WHERE conversation_id = ?') + .bind(id) + .first<{ count: number }>(); + expect(messagesBefore?.count).toBe(1); + + const purged = await storage.conversations.purge?.(now + 1000); + expect(purged).toBeGreaterThanOrEqual(1); + + const convAfter = await storage.conversations.get(id); + expect(convAfter).toBeNull(); + + const messagesAfter = await d1 + .prepare('SELECT count(*) as count FROM chatcops_messages WHERE conversation_id = ?') + .bind(id) + .first<{ count: number }>(); + expect(messagesAfter?.count).toBe(0); + }); + + it('handles append with empty messages', async () => { + const { d1 } = await getMiniflareBindings(); + const storage = cloudflareStorage({ d1 }); + const id = `empty-msg-${Date.now()}`; + const res = await storage.conversations.append?.(id, [], { metadata: { tag: 'init' } }); + expect(res?.version).toBe(1); + const conv = await storage.conversations.get(id); + expect(conv?.version).toBe(1); + expect(conv?.messages).toHaveLength(0); + expect(conv?.metadata).toEqual({ tag: 'init' }); + }); +}); + +afterAll(async () => { + await disposeMiniflare(); +}); diff --git a/packages/store-cloudflare/tests/conformance.test.ts b/packages/store-cloudflare/tests/conformance.test.ts new file mode 100644 index 00000000..abbc560a --- /dev/null +++ b/packages/store-cloudflare/tests/conformance.test.ts @@ -0,0 +1,25 @@ +import { runStorageConformance } from '@chatcops/core/testing'; +import { afterAll } from 'vitest'; +import { cloudflareStorage } from '../src/index.js'; +import { disposeMiniflare, getMiniflareBindings } from './miniflare.js'; + +runStorageConformance( + 'cloudflare (D1 + KV + DO)', + async () => { + const { d1, kv, counters } = await getMiniflareBindings(); + return cloudflareStorage({ d1, kv, counters }); + }, + { skip: ['kv-ttl'] } +); + +runStorageConformance( + 'cloudflare (D1 only)', + async () => { + const { d1 } = await getMiniflareBindings(); + return cloudflareStorage({ d1 }); + } +); + +afterAll(async () => { + await disposeMiniflare(); +}); diff --git a/packages/store-cloudflare/tests/durable-object.test.ts b/packages/store-cloudflare/tests/durable-object.test.ts new file mode 100644 index 00000000..643c672f --- /dev/null +++ b/packages/store-cloudflare/tests/durable-object.test.ts @@ -0,0 +1,55 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { cloudflareStorage } from '../src/index.js'; +import { disposeMiniflare, getMiniflareBindings } from './miniflare.js'; + +describe('durable objects counters', () => { + it('counts 50 concurrent increments exactly once', async () => { + const { d1, counters } = await getMiniflareBindings(); + const storage = cloudflareStorage({ d1, counters }); + const countersStore = storage.counters; + expect(countersStore).toBeDefined(); + if (!countersStore) throw new Error('counters not defined'); + + const key = `do-race-${Date.now()}`; + + const results = await Promise.all( + Array.from({ length: 50 }, () => countersStore.increment(key, 1, 60_000)) + ); + + expect(results.map((r) => r.value).sort((a, b) => a - b)).toEqual( + Array.from({ length: 50 }, (_, i) => i + 1) + ); + expect((await countersStore.get(key))?.value).toBe(50); + }); + + it('resets counter and cleans storage after ttl alarm fires', async () => { + const { mf, d1, counters } = await getMiniflareBindings(); + const storage = cloudflareStorage({ d1, counters }); + const countersStore = storage.counters; + expect(countersStore).toBeDefined(); + if (!countersStore) throw new Error('counters not defined'); + + const key = `do-ttl-${Date.now()}`; + + await countersStore.increment(key, 5, 50); + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(await countersStore.get(key)).toBeNull(); + + const doId = counters.idFromName(key); + const doStorage = await mf.unsafeGetDurableObjectStorage('test-worker', 'ChatCopsCounter', { + id: doId.toString(), + }); + const kvTable = await doStorage.exec( + "SELECT count(*) as count FROM sqlite_master WHERE type='table' AND name='_cf_KV'" + ); + expect(Number(kvTable[0]?.count ?? 0)).toBe(0); + + const next = await countersStore.increment(key, 2, 60_000); + expect(next.value).toBe(2); + }); +}); + +afterAll(async () => { + await disposeMiniflare(); +}); diff --git a/packages/store-cloudflare/tests/miniflare.ts b/packages/store-cloudflare/tests/miniflare.ts new file mode 100644 index 00000000..44d33c17 --- /dev/null +++ b/packages/store-cloudflare/tests/miniflare.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs'; +import { Miniflare } from 'miniflare'; +import type { ChatCopsCounter, D1Database, DurableObjectNamespace, KVNamespace } from '../src/index.js'; + +let mf: Miniflare | undefined; +let d1Instance: D1Database | undefined; +let kvInstance: KVNamespace | undefined; +let countersInstance: DurableObjectNamespace | undefined; + +export async function getMiniflareBindings() { + if (!mf) { + const countersPath = new URL('../dist/counters.js', import.meta.url); + const countersSource = readFileSync(countersPath, 'utf8'); + + mf = new Miniflare({ + name: 'test-worker', + compatibilityDate: '2026-07-30', + compatibilityFlags: ['nodejs_compat'], + modules: [ + { + type: 'ESModule', + path: 'index.js', + contents: ` + export { ChatCopsCounter } from './counters.js'; + export default { + fetch() { return new Response('ok'); } + }; + `, + }, + { + type: 'ESModule', + path: 'counters.js', + contents: countersSource, + }, + ], + d1Databases: ['DB'], + kvNamespaces: ['KV'], + durableObjects: { + COUNTERS: { + className: 'ChatCopsCounter', + useSQLite: true, + }, + }, + unsafeInspectDurableObjects: true, + }); + await mf.ready; + d1Instance = (await mf.getD1Database('DB')) as unknown as D1Database; + kvInstance = (await mf.getKVNamespace('KV')) as unknown as KVNamespace; + countersInstance = (await mf.getDurableObjectNamespace('COUNTERS')) as unknown as DurableObjectNamespace; + } + if (!d1Instance || !kvInstance || !countersInstance) { + throw new Error('Failed to initialize Miniflare bindings'); + } + return { mf, d1: d1Instance, kv: kvInstance, counters: countersInstance }; +} + +export async function disposeMiniflare() { + await mf?.dispose(); + mf = undefined; +} diff --git a/packages/store-cloudflare/tsconfig.json b/packages/store-cloudflare/tsconfig.json new file mode 100644 index 00000000..57bf531c --- /dev/null +++ b/packages/store-cloudflare/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/store-cloudflare/tsconfig.test.json b/packages/store-cloudflare/tsconfig.test.json new file mode 100644 index 00000000..1cdaf211 --- /dev/null +++ b/packages/store-cloudflare/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "tests"] +} diff --git a/packages/store-cloudflare/vitest.config.ts b/packages/store-cloudflare/vitest.config.ts new file mode 100644 index 00000000..43bc7fb5 --- /dev/null +++ b/packages/store-cloudflare/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + testTimeout: 20_000, + hookTimeout: 20_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fb700a3..ecce273e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,24 @@ importers: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.3.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.3.2(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)) + packages/store-cloudflare: + devDependencies: + '@chatcops/core': + specifier: workspace:* + version: link:../core + '@types/node': + specifier: ^22 + version: 22.20.5 + miniflare: + specifier: ^4 + version: 4.20260730.0 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.5)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.3.2(@types/node@22.20.5)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)) + packages/store-postgres: devDependencies: '@chatcops/core': @@ -609,30 +627,60 @@ packages: vite: ^6.1.0 || ^7.0.0 || ^8.0.0 wrangler: ^4.146.0 + '@cloudflare/workerd-darwin-64@1.20260730.1': + resolution: {integrity: sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-64@1.20261001.1': resolution: {integrity: sha512-4cgSgDf28JSw/P5Dj5GCS59hzVqS5XnmGAWNkvYHLI6ODU9idGaMMNEuhJXDkEG/lsABmlVlnCgsdh2VbKWepw==} engines: {node: '>=16'} cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260730.1': + resolution: {integrity: sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20261001.1': resolution: {integrity: sha512-8ulAWruEVouNmEIsQsy9WSSCS9zkLu93W2MTwp5esiFyoPp05BNSVFIFsYSPi1pkFmBBd7fsnwMpbLkaJLaVPQ==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-linux-64@1.20260730.1': + resolution: {integrity: sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-64@1.20261001.1': resolution: {integrity: sha512-kZbTZJGrhsMOdqZ2BIybjaBRLZjYsRLWj7mcNw/6Y3hodOhp5MrNzcz4iWGwNVWwyIV+7Pl+/LX5VcgnRqdHOg==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260730.1': + resolution: {integrity: sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20261001.1': resolution: {integrity: sha512-oOk3Zj6k/8oP0FJgZBWDn7+BqbsqIMEMaV95pulHPVbwVu4wYoLfQq2hp0vkbCNsCFLqZb7cqixXdrwD54ZIow==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-windows-64@1.20260730.1': + resolution: {integrity: sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workerd-windows-64@1.20261001.1': resolution: {integrity: sha512-uRxm5W4VyBkoSaoP1BfOuH0873tE+vxsknF4sab6/5YIRvIAufChq3QDp+zlAn67PuGPGcn7W8QraA0t4ucmOQ==} engines: {node: '>=16'} @@ -1011,6 +1059,12 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-arm64@0.35.4': resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} @@ -1023,6 +1077,12 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + '@img/sharp-darwin-x64@0.35.4': resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} @@ -1035,6 +1095,11 @@ packages: cpu: [x64] os: [darwin] + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-freebsd-wasm32@0.35.4': resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} @@ -1045,6 +1110,11 @@ packages: engines: {node: '>=20.9.0'} os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.3': resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] @@ -1055,6 +1125,11 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.3': resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] @@ -1065,6 +1140,11 @@ packages: cpu: [x64] os: [darwin] + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linux-arm64@1.3.3': resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] @@ -1075,6 +1155,11 @@ packages: cpu: [arm64] os: [linux] + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + '@img/sharp-libvips-linux-arm@1.3.3': resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] @@ -1085,6 +1170,11 @@ packages: cpu: [arm] os: [linux] + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + '@img/sharp-libvips-linux-ppc64@1.3.3': resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] @@ -1095,6 +1185,11 @@ packages: cpu: [ppc64] os: [linux] + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + '@img/sharp-libvips-linux-riscv64@1.3.3': resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] @@ -1105,6 +1200,11 @@ packages: cpu: [riscv64] os: [linux] + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + '@img/sharp-libvips-linux-s390x@1.3.3': resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] @@ -1115,6 +1215,11 @@ packages: cpu: [s390x] os: [linux] + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linux-x64@1.3.3': resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] @@ -1125,6 +1230,11 @@ packages: cpu: [x64] os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] @@ -1135,6 +1245,11 @@ packages: cpu: [arm64] os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.3.3': resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] @@ -1145,6 +1260,12 @@ packages: cpu: [x64] os: [linux] + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + '@img/sharp-linux-arm64@0.35.4': resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} @@ -1157,6 +1278,12 @@ packages: cpu: [arm64] os: [linux] + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + '@img/sharp-linux-arm@0.35.4': resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} @@ -1169,6 +1296,12 @@ packages: cpu: [arm] os: [linux] + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + '@img/sharp-linux-ppc64@0.35.4': resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} @@ -1181,6 +1314,12 @@ packages: cpu: [ppc64] os: [linux] + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + '@img/sharp-linux-riscv64@0.35.4': resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} @@ -1193,6 +1332,12 @@ packages: cpu: [riscv64] os: [linux] + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + '@img/sharp-linux-s390x@0.35.4': resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} @@ -1205,6 +1350,12 @@ packages: cpu: [s390x] os: [linux] + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + '@img/sharp-linux-x64@0.35.4': resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} @@ -1217,6 +1368,12 @@ packages: cpu: [x64] os: [linux] + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + '@img/sharp-linuxmusl-arm64@0.35.4': resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} @@ -1229,6 +1386,12 @@ packages: cpu: [arm64] os: [linux] + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + '@img/sharp-linuxmusl-x64@0.35.4': resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} @@ -1241,6 +1404,10 @@ packages: cpu: [x64] os: [linux] + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + '@img/sharp-wasm32@0.35.4': resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} @@ -1249,6 +1416,11 @@ packages: resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-webcontainers-wasm32@0.35.4': resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} @@ -1259,6 +1431,12 @@ packages: engines: {node: '>=20.9.0'} cpu: [wasm32] + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-arm64@0.35.4': resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} @@ -1271,6 +1449,12 @@ packages: cpu: [arm64] os: [win32] + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-ia32@0.35.4': resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} @@ -1283,6 +1467,12 @@ packages: cpu: [ia32] os: [win32] + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@img/sharp-win32-x64@0.35.4': resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} @@ -3241,6 +3431,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + miniflare@4.20260730.0: + resolution: {integrity: sha512-1Z9SB9r/o//80UA02Re3QhtcecSHAyAjf5EcKBfQVlQrCg7Miy79hl2PvtkwFLIaJ5rcrOPdDcRr577okwZPsg==} + engines: {node: '>=22.0.0'} + hasBin: true + miniflare@5.20261001.0-alpha: resolution: {integrity: sha512-GaimS5mSIOMyvd16ga+e1/QkI8cmp3z35QPHFex0DktqNQf2RVZQwMPQXdyoUreUiFP/0Gpe2MoQInTyMAD5xA==} engines: {node: '>=22.0.0'} @@ -3775,6 +3970,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + sharp@0.35.4: resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} @@ -4015,6 +4214,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + undici@7.29.1: resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} @@ -4309,6 +4512,11 @@ packages: engines: {node: '>=8'} hasBin: true + workerd@1.20260730.1: + resolution: {integrity: sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==} + engines: {node: '>=16'} + hasBin: true + workerd@1.20261001.1: resolution: {integrity: sha512-d/SIYHFO0PT/wiFZg8in4NpRIxYuFwslX1HdylOtWkBIIUmSpkGFhK820cV84XACFylwJ48xuRoWW/8DWDPsPQ==} engines: {node: '>=16'} @@ -4886,18 +5094,33 @@ snapshots: - bufferutil - utf-8-validate + '@cloudflare/workerd-darwin-64@1.20260730.1': + optional: true + '@cloudflare/workerd-darwin-64@1.20261001.1': optional: true + '@cloudflare/workerd-darwin-arm64@1.20260730.1': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20261001.1': optional: true + '@cloudflare/workerd-linux-64@1.20260730.1': + optional: true + '@cloudflare/workerd-linux-64@1.20261001.1': optional: true + '@cloudflare/workerd-linux-arm64@1.20260730.1': + optional: true + '@cloudflare/workerd-linux-arm64@1.20261001.1': optional: true + '@cloudflare/workerd-windows-64@1.20260730.1': + optional: true + '@cloudflare/workerd-windows-64@1.20261001.1': optional: true @@ -5139,6 +5362,11 @@ snapshots: '@img/colour@1.1.0': {} + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.3.3 @@ -5149,6 +5377,11 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.3.3 @@ -5159,6 +5392,11 @@ snapshots: '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: '@img/sharp-wasm32': 0.35.4 @@ -5169,66 +5407,101 @@ snapshots: '@img/sharp-wasm32': 0.35.5 optional: true + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true '@img/sharp-libvips-darwin-x64@1.3.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true '@img/sharp-libvips-linux-arm64@1.3.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + '@img/sharp-libvips-linux-arm@1.3.3': optional: true '@img/sharp-libvips-linux-arm@1.3.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true '@img/sharp-libvips-linux-s390x@1.3.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + '@img/sharp-libvips-linux-x64@1.3.3': optional: true '@img/sharp-libvips-linux-x64@1.3.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.3.3 @@ -5239,6 +5512,11 @@ snapshots: '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + '@img/sharp-linux-arm@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.3.3 @@ -5249,6 +5527,11 @@ snapshots: '@img/sharp-libvips-linux-arm': 1.3.4 optional: true + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.3.3 @@ -5259,6 +5542,11 @@ snapshots: '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.3.3 @@ -5269,6 +5557,11 @@ snapshots: '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.3.3 @@ -5279,6 +5572,11 @@ snapshots: '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + '@img/sharp-linux-x64@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.3.3 @@ -5289,6 +5587,11 @@ snapshots: '@img/sharp-libvips-linux-x64': 1.3.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 @@ -5299,6 +5602,11 @@ snapshots: '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.3.3 @@ -5309,6 +5617,11 @@ snapshots: '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 @@ -5319,6 +5632,11 @@ snapshots: '@emnapi/runtime': 1.11.3 optional: true + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: '@img/sharp-wasm32': 0.35.4 @@ -5329,18 +5647,27 @@ snapshots: '@img/sharp-wasm32': 0.35.5 optional: true + '@img/sharp-win32-arm64@0.35.2': + optional: true + '@img/sharp-win32-arm64@0.35.4': optional: true '@img/sharp-win32-arm64@0.35.5': optional: true + '@img/sharp-win32-ia32@0.35.2': + optional: true + '@img/sharp-win32-ia32@0.35.4': optional: true '@img/sharp-win32-ia32@0.35.5': optional: true + '@img/sharp-win32-x64@0.35.2': + optional: true + '@img/sharp-win32-x64@0.35.4': optional: true @@ -7763,6 +8090,18 @@ snapshots: dependencies: mime-db: 1.54.0 + miniflare@4.20260730.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.28.0 + workerd: 1.20260730.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + miniflare@5.20261001.0-alpha(@types/node@25.3.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -8407,6 +8746,38 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + sharp@0.35.4(@types/node@25.3.3): dependencies: '@img/colour': 1.1.0 @@ -8683,6 +9054,7 @@ snapshots: undici-types@7.18.2: optional: true + undici@7.28.0: {} undici@7.29.1: {} @@ -8954,6 +9326,14 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + workerd@1.20260730.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260730.1 + '@cloudflare/workerd-darwin-arm64': 1.20260730.1 + '@cloudflare/workerd-linux-64': 1.20260730.1 + '@cloudflare/workerd-linux-arm64': 1.20260730.1 + '@cloudflare/workerd-windows-64': 1.20260730.1 + workerd@1.20261001.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20261001.1 From 238eaefb33f2e9d49873a1e4465f2b926e831ce9 Mon Sep 17 00:00:00 2001 From: SrujanKaleru Date: Thu, 22 Oct 2026 21:03:44 +0530 Subject: [PATCH 74/80] feat(core): buffer analytics events into sinks (#314) * feat(core): buffer analytics events into sinks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(core): document buffered analytics sinks --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> --- .changeset/quiet-sinks-batch.md | 5 + packages/core/src/analytics/index.ts | 1 + packages/core/src/analytics/sink.ts | 124 +++++++++ packages/core/src/index.ts | 2 +- packages/core/tests/analytics/sink.test.ts | 288 ++++++++++++++++++++ website/src/content/docs/core/analytics.mdx | 33 ++- 6 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 .changeset/quiet-sinks-batch.md create mode 100644 packages/core/src/analytics/sink.ts create mode 100644 packages/core/tests/analytics/sink.test.ts diff --git a/.changeset/quiet-sinks-batch.md b/.changeset/quiet-sinks-batch.md new file mode 100644 index 00000000..cbcee980 --- /dev/null +++ b/.changeset/quiet-sinks-batch.md @@ -0,0 +1,5 @@ +--- +"@chatcops/core": minor +--- + +Add buffered analytics event writing and sink-backed statistics. diff --git a/packages/core/src/analytics/index.ts b/packages/core/src/analytics/index.ts index e0b55451..a1cc2b50 100644 --- a/packages/core/src/analytics/index.ts +++ b/packages/core/src/analytics/index.ts @@ -1,2 +1,3 @@ export { AnalyticsCollector } from './collector.js'; export type { AnalyticsStats } from './collector.js'; +export { BufferedEventSink, statsFromSink } from './sink.js'; diff --git a/packages/core/src/analytics/sink.ts b/packages/core/src/analytics/sink.ts new file mode 100644 index 00000000..d0383190 --- /dev/null +++ b/packages/core/src/analytics/sink.ts @@ -0,0 +1,124 @@ +import type { AnalyticsEvent } from '../types.js'; +import type { EventSink } from '../storage/types.js'; +import type { AnalyticsStats } from './collector.js'; + +const DEFAULT_MAX_BATCH = 50; +const DEFAULT_FLUSH_MS = 2000; + +export class BufferedEventSink { + private readonly events: AnalyticsEvent[] = []; + private readonly maxBatch: number; + private readonly flushMs: number; + private timer: ReturnType | undefined; + private flushPromise: Promise | undefined; + + constructor( + private readonly sink: EventSink, + options?: { maxBatch?: number; flushMs?: number } + ) { + this.maxBatch = options?.maxBatch ?? DEFAULT_MAX_BATCH; + this.flushMs = options?.flushMs ?? DEFAULT_FLUSH_MS; + + if (!Number.isInteger(this.maxBatch) || this.maxBatch < 1) { + throw new RangeError('maxBatch must be a positive integer.'); + } + if (!Number.isFinite(this.flushMs) || this.flushMs < 0) { + throw new RangeError('flushMs must be a non-negative finite number.'); + } + } + + track(type: string, data?: Record): void { + const wasEmpty = this.events.length === 0; + this.events.push({ type, data, timestamp: Date.now() }); + + if (wasEmpty) this.scheduleFlush(); + if (this.events.length >= this.maxBatch) this.startAutomaticFlush(); + } + + flush(): Promise { + if (this.flushPromise) return this.flushPromise; + + this.clearTimer(); + const drain = Promise.resolve().then(async () => { + while (this.events.length > 0) { + this.clearTimer(); + const batch = this.events.splice(0, this.maxBatch); + + try { + await this.sink.write(batch); + } catch (error) { + console.error(`[chatcops] analytics write failed: ${errorMessage(error)}`); + } + } + }); + + const flushPromise = drain.finally(() => { + if (this.flushPromise === flushPromise) this.flushPromise = undefined; + if (this.events.length > 0) this.startAutomaticFlush(); + }); + this.flushPromise = flushPromise; + return flushPromise; + } + + private scheduleFlush(): void { + try { + const timer = setTimeout(() => { + this.timer = undefined; + this.startAutomaticFlush(); + }, this.flushMs); + this.timer = timer; + + if (typeof timer === 'object' && timer !== null && 'unref' in timer && typeof timer.unref === 'function') { + timer.unref(); + } + } catch (error) { + this.clearTimer(); + console.error(`[chatcops] analytics timer failed: ${errorMessage(error)}`); + this.startAutomaticFlush(); + } + } + + private clearTimer(): void { + if (this.timer === undefined) return; + clearTimeout(this.timer); + this.timer = undefined; + } + + private startAutomaticFlush(): void { + void this.flush().catch((error: unknown) => { + console.error(`[chatcops] analytics flush failed: ${errorMessage(error)}`); + }); + } +} + +export async function statsFromSink( + sink: EventSink, + range: { from: number; to: number } +): Promise { + if (!sink.query) { + throw new Error('Event sink does not support querying; implement EventSink.query to calculate analytics statistics.'); + } + + const counts = await sink.query(range); + const eventCounts = new Map(); + for (const { type, count } of counts) { + eventCounts.set(type, (eventCounts.get(type) ?? 0) + count); + } + + const totalConversations = eventCounts.get('conversation:started') ?? 0; + const totalMessages = + (eventCounts.get('message:sent') ?? 0) + (eventCounts.get('message:received') ?? 0); + + return { + totalConversations, + totalMessages, + leadsCapture: eventCounts.get('lead:captured') ?? 0, + averageMessagesPerConversation: totalConversations > 0 ? totalMessages / totalConversations : 0, + eventCounts: Object.fromEntries(eventCounts), + }; +} + +function errorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/[\r\n]+/g, ' '); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f491a74b..a03967ac 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -124,7 +124,7 @@ export { createRateLimiter } from './limits/rate-limit.js'; export type { RateLimitRule, RateLimitResult, RateLimiterOptions } from './limits/rate-limit.js'; // Analytics -export { AnalyticsCollector } from './analytics/index.js'; +export { AnalyticsCollector, BufferedEventSink, statsFromSink } from './analytics/index.js'; export type { AnalyticsStats } from './analytics/index.js'; // i18n diff --git a/packages/core/tests/analytics/sink.test.ts b/packages/core/tests/analytics/sink.test.ts new file mode 100644 index 00000000..2b3c47b1 --- /dev/null +++ b/packages/core/tests/analytics/sink.test.ts @@ -0,0 +1,288 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AnalyticsCollector } from '../../src/analytics/collector.js'; +import { BufferedEventSink, statsFromSink } from '../../src/analytics/sink.js'; +import { memoryStorage } from '../../src/storage/memory.js'; +import type { AnalyticsEvent } from '../../src/types.js'; + +describe('BufferedEventSink', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('uses a batch size of 50 and a flush interval of 2000ms by default', async () => { + vi.useFakeTimers(); + const write = vi.fn(async (_events: AnalyticsEvent[]) => {}); + const buffered = new BufferedEventSink({ write }); + + buffered.track('timer:event'); + await vi.advanceTimersByTimeAsync(1999); + expect(write).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(1); + + await vi.advanceTimersByTimeAsync(1); + expect(write).toHaveBeenCalledTimes(1); + expect(write.mock.calls[0][0].map((event) => event.type)).toEqual(['timer:event']); + expect(vi.getTimerCount()).toBe(0); + + for (let index = 0; index < 49; index++) buffered.track('test:event'); + expect(write).toHaveBeenCalledTimes(1); + buffered.track('test:event'); + await vi.advanceTimersByTimeAsync(0); + expect(write).toHaveBeenCalledTimes(2); + expect(write.mock.calls[1][0]).toHaveLength(50); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not create a timer in the constructor', () => { + vi.useFakeTimers(); + + new BufferedEventSink({ write: async () => {} }); + + expect(vi.getTimerCount()).toBe(0); + }); + + it('creates a timer on the first event in an empty buffer', () => { + vi.useFakeTimers(); + const buffered = new BufferedEventSink({ write: async () => {} }); + + buffered.track('test:event'); + + expect(vi.getTimerCount()).toBe(1); + }); + + it('flushes queued events when the configured timer expires', async () => { + vi.useFakeTimers(); + const write = vi.fn(async (_events: AnalyticsEvent[]) => {}); + const buffered = new BufferedEventSink({ write }, { flushMs: 100 }); + + buffered.track('test:event'); + await vi.advanceTimersByTimeAsync(99); + expect(write).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + expect(write).toHaveBeenCalledTimes(1); + expect(write.mock.calls[0][0].map((event) => event.type)).toEqual(['test:event']); + expect(vi.getTimerCount()).toBe(0); + }); + + it('starts a flush immediately when maxBatch is reached', async () => { + vi.useFakeTimers(); + const write = vi.fn(async (_events: AnalyticsEvent[]) => {}); + const buffered = new BufferedEventSink({ write }, { maxBatch: 2, flushMs: 10_000 }); + + buffered.track('first'); + buffered.track('second'); + await vi.advanceTimersByTimeAsync(0); + + expect(write).toHaveBeenCalledTimes(1); + expect(write.mock.calls[0][0].map((event) => event.type)).toEqual(['first', 'second']); + }); + + it('allows repeated flushes when the buffer is empty', async () => { + const write = vi.fn(async (_events: AnalyticsEvent[]) => {}); + const buffered = new BufferedEventSink({ write }); + + await buffered.flush(); + await buffered.flush(); + + expect(write).not.toHaveBeenCalled(); + }); + + it('shares concurrent flushes without writing an event twice', async () => { + let releaseWrite!: () => void; + const writeGate = new Promise((resolve) => { + releaseWrite = resolve; + }); + const write = vi.fn(async (_events: AnalyticsEvent[]) => writeGate); + const buffered = new BufferedEventSink({ write }); + + buffered.track('one'); + const firstFlush = buffered.flush(); + const secondFlush = buffered.flush(); + + expect(secondFlush).toBe(firstFlush); + await Promise.resolve(); + expect(write).toHaveBeenCalledTimes(1); + releaseWrite(); + await Promise.all([firstFlush, secondFlush]); + + expect(write.mock.calls.flatMap(([events]) => events.map((event) => event.type))).toEqual(['one']); + }); + + it('retains and flushes events tracked while a write is in flight', async () => { + let releaseFirstWrite!: () => void; + const firstWriteGate = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + const write = vi + .fn() + .mockImplementationOnce(async (_events: AnalyticsEvent[]) => firstWriteGate) + .mockImplementation(async (_events: AnalyticsEvent[]) => {}); + const buffered = new BufferedEventSink({ write }, { maxBatch: 10, flushMs: 10_000 }); + + buffered.track('first'); + const flush = buffered.flush(); + await Promise.resolve(); + expect(write).toHaveBeenCalledTimes(1); + + buffered.track('second'); + releaseFirstWrite(); + await flush; + + expect(write.mock.calls.map(([events]) => events.map((event) => event.type))).toEqual([['first'], ['second']]); + }); + + it('logs one formatted line and drops a failed batch', async () => { + const write = vi.fn(async (_events: AnalyticsEvent[]) => { + throw new Error('storage unavailable'); + }); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const buffered = new BufferedEventSink({ write }, { maxBatch: 1 }); + + buffered.track('failed:event'); + await buffered.flush(); + + expect(write).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith('[chatcops] analytics write failed: storage unavailable'); + await buffered.flush(); + expect(write).toHaveBeenCalledTimes(1); + }); + + it('does not throw from track when the automatic write fails', async () => { + const write = vi.fn(async (_events: AnalyticsEvent[]) => { + throw new Error('write failed'); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const buffered = new BufferedEventSink({ write }, { maxBatch: 1 }); + + expect(() => buffered.track('test:event')).not.toThrow(); + await buffered.flush(); + + expect(write).toHaveBeenCalledTimes(1); + }); + + it('handles synchronous write failures like rejected writes', async () => { + const write = vi.fn((_events: AnalyticsEvent[]) => { + throw new Error('synchronous write failure'); + }); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const buffered = new BufferedEventSink({ write }); + + buffered.track('test:event'); + await buffered.flush(); + + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith('[chatcops] analytics write failed: synchronous write failure'); + }); + + it('clears the timer when a manual flush starts', async () => { + vi.useFakeTimers(); + const buffered = new BufferedEventSink({ write: async () => {} }, { flushMs: 10_000 }); + + buffered.track('test:event'); + expect(vi.getTimerCount()).toBe(1); + + await buffered.flush(); + + expect(vi.getTimerCount()).toBe(0); + }); + + it('clears the timer after an automatic flush', async () => { + vi.useFakeTimers(); + const buffered = new BufferedEventSink({ write: async () => {} }, { flushMs: 100 }); + + buffered.track('test:event'); + await vi.advanceTimersByTimeAsync(100); + + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('statsFromSink', () => { + it('throws an actionable error when querying is unsupported', async () => { + await expect(statsFromSink({ write: async () => {} }, { from: 0, to: 1 })).rejects.toThrow( + 'Event sink does not support querying' + ); + }); + + it('counts all supported event types and preserves the AnalyticsStats shape', async () => { + const storage = memoryStorage(); + const eventsSink = storage.events; + if (!eventsSink) throw new Error('memoryStorage did not provide an event sink'); + await eventsSink.write([ + { type: 'conversation:started', data: { conversationId: 'one' }, timestamp: 1 }, + { type: 'message:sent', timestamp: 2 }, + { type: 'message:received', timestamp: 3 }, + { type: 'lead:captured', timestamp: 4 }, + { type: 'other:event', timestamp: 5 }, + ]); + + await expect(statsFromSink(eventsSink, { from: 0, to: 10 })).resolves.toEqual({ + totalConversations: 1, + totalMessages: 2, + leadsCapture: 1, + averageMessagesPerConversation: 2, + eventCounts: { + 'conversation:started': 1, + 'message:sent': 1, + 'message:received': 1, + 'lead:captured': 1, + 'other:event': 1, + }, + }); + }); + + it('counts every conversation:started event as one conversation', async () => { + const storage = memoryStorage(); + const eventsSink = storage.events; + if (!eventsSink) throw new Error('memoryStorage did not provide an event sink'); + await eventsSink.write([ + { type: 'conversation:started', data: { conversationId: 'same-id' }, timestamp: 1 }, + { type: 'conversation:started', data: { conversationId: 'same-id' }, timestamp: 2 }, + ]); + + await expect(statsFromSink(eventsSink, { from: 0, to: 10 })).resolves.toMatchObject({ + totalConversations: 2, + eventCounts: { 'conversation:started': 2 }, + }); + }); + + it('uses inclusive from and exclusive to range boundaries', async () => { + const storage = memoryStorage(); + const eventsSink = storage.events; + if (!eventsSink) throw new Error('memoryStorage did not provide an event sink'); + await eventsSink.write([ + { type: 'message:sent', timestamp: 9 }, + { type: 'message:sent', timestamp: 10 }, + { type: 'message:sent', timestamp: 19 }, + { type: 'message:sent', timestamp: 20 }, + ]); + + await expect(statsFromSink(eventsSink, { from: 10, to: 20 })).resolves.toMatchObject({ + totalMessages: 2, + eventCounts: { 'message:sent': 2 }, + }); + }); + + it('matches AnalyticsCollector stats for equivalent in-range events', async () => { + const storage = memoryStorage(); + const eventsSink = storage.events; + if (!eventsSink) throw new Error('memoryStorage did not provide an event sink'); + const events: AnalyticsEvent[] = [ + { type: 'conversation:started', data: { conversationId: 'one' }, timestamp: 10 }, + { type: 'conversation:started', data: { conversationId: 'two' }, timestamp: 11 }, + { type: 'message:sent', data: { messageId: 'sent' }, timestamp: 12 }, + { type: 'message:received', data: { messageId: 'received' }, timestamp: 13 }, + { type: 'lead:captured', data: { leadId: 'lead' }, timestamp: 14 }, + { type: 'other:event', timestamp: 15 }, + ]; + await eventsSink.write(events); + const collector = new AnalyticsCollector(); + for (const { type, data } of events) collector.track(type, data); + + await expect(statsFromSink(eventsSink, { from: 10, to: 16 })).resolves.toEqual(collector.getStats()); + }); +}); diff --git a/website/src/content/docs/core/analytics.mdx b/website/src/content/docs/core/analytics.mdx index 823fe783..249de209 100644 --- a/website/src/content/docs/core/analytics.mdx +++ b/website/src/content/docs/core/analytics.mdx @@ -119,21 +119,32 @@ interface AnalyticsEvent { ## Storage -The built-in collector stores events in memory (max 1000 events). For production use, export events to your analytics platform: +The built-in collector stores events in memory (max 1000 events). To write events to a storage-backed `EventSink`, use `BufferedEventSink`. It batches up to 50 events by default and flushes every 2 seconds. Call `flush()` when you need to wait for queued writes, such as before a process exits: ```typescript -const collector = new AnalyticsCollector(); - -// Periodically flush to your analytics service -setInterval(() => { - const events = collector.getEvents(); - if (events.length > 0) { - sendToAnalytics(events); - collector.clear(); - } -}, 60_000); // Every minute +import { BufferedEventSink, memoryStorage, statsFromSink } from '@chatcops/core'; + +const storage = memoryStorage(); +const eventSink = storage.events; +if (!eventSink) throw new Error('Event storage is not configured'); + +const analytics = new BufferedEventSink(eventSink, { + maxBatch: 50, + flushMs: 2000, +}); + +analytics.track('custom:event', { key: 'value' }); +await analytics.flush(); + +const now = Date.now(); +const stats = await statsFromSink(eventSink, { + from: now - 24 * 60 * 60 * 1000, + to: now, +}); ``` +Use a persistent `EventSink` implementation when events need to be shared across processes or survive restarts. Writes that fail are logged and dropped so analytics does not interrupt chat requests. `statsFromSink` requires the sink's optional `query` method; the range includes `from` and excludes `to`. It counts each `conversation:started` event as one conversation. + ## Client-Side Events Combine with widget events for full-stack analytics: From 63680cf09cadaa5e421df2b9d05b91a181edb9ad Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:10:40 +0530 Subject: [PATCH 75/80] feat(store-cloudflare): add rowHook and keep turn locks in D1 (#315) --- .changeset/cloudflare-row-hook.md | 5 + packages/store-cloudflare/README.md | 34 ++++--- .../store-cloudflare/src/conversations.ts | 49 +++++++--- packages/store-cloudflare/src/index.ts | 30 ++++-- packages/store-cloudflare/src/kv.ts | 24 +---- packages/store-cloudflare/src/row-hook.ts | 64 +++++++++++++ packages/store-cloudflare/src/types.ts | 14 --- .../store-cloudflare/tests/cloudflare.test.ts | 5 +- .../tests/conformance.test.ts | 9 +- packages/store-cloudflare/tests/miniflare.ts | 9 +- .../store-cloudflare/tests/row-hook.test.ts | 96 +++++++++++++++++++ 11 files changed, 253 insertions(+), 86 deletions(-) create mode 100644 .changeset/cloudflare-row-hook.md create mode 100644 packages/store-cloudflare/src/row-hook.ts create mode 100644 packages/store-cloudflare/tests/row-hook.test.ts diff --git a/.changeset/cloudflare-row-hook.md b/.changeset/cloudflare-row-hook.md new file mode 100644 index 00000000..21b29299 --- /dev/null +++ b/.changeset/cloudflare-row-hook.md @@ -0,0 +1,5 @@ +--- +"@chatcops/store-cloudflare": minor +--- + +`extraColumns` and `rowHook` stamp columns you've added (a tenant or site id) on every new conversation and message row, as in the Postgres and SQLite stores. The `kv` option is gone: ChatCops only uses key-value storage for turn locks, which need the atomic `setIfAbsent` that Workers KV doesn't have, so turn locks always live in D1 and hold across isolates. diff --git a/packages/store-cloudflare/README.md b/packages/store-cloudflare/README.md index 009c8d45..74407e09 100644 --- a/packages/store-cloudflare/README.md +++ b/packages/store-cloudflare/README.md @@ -1,12 +1,12 @@ # @chatcops/store-cloudflare -Cloudflare storage for [ChatCops](https://chat.codercops.com): conversations, exact atomic counters, turn locks, and analytics events backed by Cloudflare D1, Workers KV, and Durable Objects. +Cloudflare storage for [ChatCops](https://chat.codercops.com): conversations, exact atomic counters, turn locks, and analytics events backed by Cloudflare D1 and Durable Objects. ## Features - **Conversations & Messages:** Durable history in Cloudflare D1 SQLite with concurrency-safe `append` without interactive transactions. - **Exact Counters:** Globally coordinated rate limits and token budgets through Durable Objects (`ChatCopsCounter`), with automatic fallback to D1 `UPSERT ... RETURNING`. -- **Key-Value Store:** Ephemeral key-value caching using Workers KV or D1 SQLite fallback. Turn locks require the D1 fallback because Workers KV does not support atomic `setIfAbsent`; Workers KV also enforces a 60-second minimum TTL. +- **Turn locks:** In D1, with an atomic `setIfAbsent`, so one reply per conversation holds across every isolate. Workers KV isn't used: it has no atomic `setIfAbsent`, and its TTLs start at 60 seconds. - **Analytics Events:** Time-bucketed event queries in D1 for dashboards and reporting. ## Install @@ -46,12 +46,6 @@ Configure your D1 database, Durable Object class binding, and migration in `wran "tag": "v1", "new_sqlite_classes": ["ChatCopsCounter"] } - ], - "kv_namespaces": [ - { - "binding": "KV", - "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - } ] } ``` @@ -69,7 +63,6 @@ export { ChatCopsCounter }; interface Env { DB: D1Database; - KV?: KVNamespace; COUNTERS?: DurableObjectNamespace; ANTHROPIC_API_KEY: string; } @@ -83,7 +76,6 @@ export default { systemPrompt: 'You are a helpful assistant.', storage: cloudflareStorage({ d1: env.DB, - kv: env.KV, counters: env.COUNTERS, }), }); @@ -98,10 +90,30 @@ export default { | Option | Type | Required | Description | |---|---|---|---| | `d1` | `D1Database` | **Yes** | Cloudflare D1 database binding used for conversations, messages, events, turn locks, and fallback storage. | -| `kv` | `KVNamespace` | No | Cloudflare Workers KV namespace for fast key-value storage (minimum TTL: 60 seconds). Turn locks require the D1 fallback because Workers KV does not support atomic `setIfAbsent`. When omitted, D1 is used as fallback. | | `counters` | `DurableObjectNamespace` | No | Durable Objects namespace for exact distributed counter coordination. When omitted, D1 atomic upserts are used. | +| `extraColumns` | `string[]` or `Record` | No | Columns you've added yourself, filled in by `rowHook`. See below. | +| `rowHook` | `(context) => Record` | No | Values for `extraColumns` on each new row. | | `now` | `() => number` | No | Clock function for testing (defaults to `Date.now`). | +## Extra columns + +To stamp columns of your own (a tenant or site id) on every new row, add them with your own `ALTER TABLE` and pass `extraColumns` with a `rowHook`: + +```sql +ALTER TABLE chatcops_conversations ADD COLUMN workspace_id TEXT; +ALTER TABLE chatcops_messages ADD COLUMN workspace_id TEXT; +``` + +```ts +cloudflareStorage({ + d1: env.DB, + extraColumns: ['workspace_id'], + rowHook: ({ metadata }) => ({ workspace_id: metadata?.workspaceId }), +}); +``` + +The hook runs when a row is created, never on updates. It gets the table, the conversation id, the metadata passed with the write, and the message for message rows. Returning a key that isn't in `extraColumns` throws, and nothing from that write is stored. Values are bound as parameters, never put into the SQL, and must be strings, numbers, booleans (stored as 1 or 0, so use an `INTEGER` column) or null. The `{ workspace_id: 'uuid' }` form from `@chatcops/store-postgres` works too; D1 ignores the type. + ## Concurrency & Durability Cloudflare D1 does not provide interactive transactions. To ensure data integrity under concurrent traffic: diff --git a/packages/store-cloudflare/src/conversations.ts b/packages/store-cloudflare/src/conversations.ts index 779c716b..48feb0da 100644 --- a/packages/store-cloudflare/src/conversations.ts +++ b/packages/store-cloudflare/src/conversations.ts @@ -7,6 +7,7 @@ import { type ConversationSummary, type ListQuery, } from '@chatcops/core'; +import type { RowHook, RowHookContext } from './row-hook.js'; import type { D1Database, D1PreparedStatement } from './types.js'; interface ConversationRow { @@ -24,11 +25,30 @@ interface MessageRow { const json = (value: unknown): string | null => (value === undefined ? null : JSON.stringify(value)); export class CloudflareConversations implements ConversationStore { + /** `, a, b` for the hook's columns, and `, ?, ?` for their values. */ + private readonly extraColumns: string; + private readonly extraMarks: string; + constructor( private readonly db: D1Database, private readonly now: () => number, - private readonly ensure: () => Promise - ) {} + private readonly ensure: () => Promise, + private readonly hook?: RowHook + ) { + this.extraColumns = hook?.columns.map((name) => `, ${name}`).join('') ?? ''; + this.extraMarks = hook?.columns.map(() => ', ?').join('') ?? ''; + } + + /** The hook's values for one new row, or nothing without a hook. */ + private extra(context: RowHookContext): unknown[] { + return this.hook ? this.hook.values(context) : []; + } + + private insertMessage(id: string, seq: number, message: ChatMessage, metadata: Record | undefined): D1PreparedStatement { + return this.db + .prepare(`INSERT INTO chatcops_messages (conversation_id, seq, id, message${this.extraColumns}) VALUES (?, ?, ?, ?${this.extraMarks})`) + .bind(id, seq, message.id, JSON.stringify(message), ...this.extra({ table: 'messages', conversationId: id, metadata, message })); + } async get(id: string): Promise { await this.ensure(); @@ -61,8 +81,8 @@ export class CloudflareConversations implements ConversationStore { const stmts: D1PreparedStatement[] = [ this.db .prepare(` - INSERT INTO chatcops_conversations (id, metadata, version, created_at, updated_at) - VALUES (?, ?, 1, ?, ?) + INSERT INTO chatcops_conversations (id, metadata, version, created_at, updated_at${this.extraColumns}) + VALUES (?, ?, 1, ?, ?${this.extraMarks}) ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, version = chatcops_conversations.version + 1, @@ -72,17 +92,15 @@ export class CloudflareConversations implements ConversationStore { conversation.id, json(conversation.metadata), conversation.createdAt, - time + time, + // Only in the INSERT half, so an update never changes them. + ...this.extra({ table: 'conversations', conversationId: conversation.id, metadata: conversation.metadata }) ), this.db.prepare('DELETE FROM chatcops_messages WHERE conversation_id = ?').bind(conversation.id), ]; for (const [index, message] of conversation.messages.entries()) { - stmts.push( - this.db - .prepare('INSERT INTO chatcops_messages (conversation_id, seq, id, message) VALUES (?, ?, ?, ?)') - .bind(conversation.id, index + 1, message.id, JSON.stringify(message)) - ); + stmts.push(this.insertMessage(conversation.id, index + 1, message, conversation.metadata)); } await this.db.batch(stmts); @@ -137,11 +155,11 @@ export class CloudflareConversations implements ConversationStore { batch.push( this.db .prepare(` - INSERT INTO chatcops_conversations (id, metadata, version, created_at, updated_at) - SELECT ?, ?, 1, ?, ? + INSERT INTO chatcops_conversations (id, metadata, version, created_at, updated_at${this.extraColumns}) + SELECT ?, ?, 1, ?, ?${this.extraMarks} WHERE NOT EXISTS (SELECT 1 FROM chatcops_conversations WHERE id = ?) `) - .bind(id, json(metadata), time, time, id) + .bind(id, json(metadata), time, time, ...this.extra({ table: 'conversations', conversationId: id, metadata: options.metadata }), id) ); } else { batch.push( @@ -159,8 +177,8 @@ export class CloudflareConversations implements ConversationStore { batch.push( this.db .prepare(` - INSERT INTO chatcops_messages (conversation_id, seq, id, message) - SELECT ?, ?, ?, ? + INSERT INTO chatcops_messages (conversation_id, seq, id, message${this.extraColumns}) + SELECT ?, ?, ?, ?${this.extraMarks} WHERE EXISTS ( SELECT 1 FROM chatcops_conversations WHERE id = ? AND version = ? AND updated_at = ? @@ -171,6 +189,7 @@ export class CloudflareConversations implements ConversationStore { lastSeq + index + 1, msg.id, JSON.stringify(msg), + ...this.extra({ table: 'messages', conversationId: id, metadata: options.metadata, message: msg }), id, nextVersion, time diff --git a/packages/store-cloudflare/src/index.ts b/packages/store-cloudflare/src/index.ts index ab1ed2ac..e6516be2 100644 --- a/packages/store-cloudflare/src/index.ts +++ b/packages/store-cloudflare/src/index.ts @@ -2,9 +2,10 @@ import type { ChatCopsStorage } from '@chatcops/core'; import { CloudflareConversations } from './conversations.js'; import { type ChatCopsCounter, D1Counters, DoCounters } from './counters.js'; import { CloudflareEvents } from './events.js'; -import { D1KvStore, WorkersKvStore } from './kv.js'; +import { D1KvStore } from './kv.js'; +import { checkRowHook, type ExtraColumns, type RowHookFunction } from './row-hook.js'; import { ensureSchema } from './schema.js'; -import type { D1Database, DurableObjectNamespace, KVNamespace } from './types.js'; +import type { D1Database, DurableObjectNamespace } from './types.js'; export { ChatCopsCounter } from './counters.js'; export { SCHEMA_SQL, SCHEMA_VERSION, initializeSchema } from './schema.js'; @@ -18,16 +19,26 @@ export type { DurableObjectState, DurableObjectStorage, DurableObjectStub, - KVNamespace, } from './types.js'; +export type { ExtraColumns, RowHookContext, RowHookFunction } from './row-hook.js'; export interface CloudflareStorageOptions { /** The D1 database binding. Required. */ d1: D1Database; - /** Optional Workers KV namespace for key-value data. */ - kv?: KVNamespace; /** Optional Durable Object namespace for exact distributed counters. */ counters?: DurableObjectNamespace; + /** + * Columns you've added to the conversations and messages tables (with your + * own `ALTER TABLE`), filled in by `rowHook` when a row is created. + */ + extraColumns?: ExtraColumns; + /** + * Values for `extraColumns` on each new conversation and message row. Runs + * on insert only, never on update. Returning a key that isn't declared in + * `extraColumns` throws, and nothing from that write is stored. Values are + * bound as parameters, never put into the SQL. + */ + rowHook?: RowHookFunction; /** Optional custom clock for testing. */ now?: () => number; } @@ -44,15 +55,16 @@ export function cloudflareStorage(options: CloudflareStorageOptions): ChatCopsSt const now = options.now ?? Date.now; const ensure = () => ensureSchema(options.d1); + const hook = checkRowHook(options.extraColumns, options.rowHook); return { - conversations: new CloudflareConversations(options.d1, now, ensure), + conversations: new CloudflareConversations(options.d1, now, ensure, hook), counters: options.counters ? new DoCounters(options.counters) : new D1Counters(options.d1, now, ensure), - kv: options.kv - ? new WorkersKvStore(options.kv) - : new D1KvStore(options.d1, now, ensure), + // Turn locks need an atomic setIfAbsent, which Workers KV doesn't have, + // so key-value data always lives in D1. + kv: new D1KvStore(options.d1, now, ensure), events: new CloudflareEvents(options.d1, ensure), close: async () => {}, }; diff --git a/packages/store-cloudflare/src/kv.ts b/packages/store-cloudflare/src/kv.ts index e50c858c..5dea4a38 100644 --- a/packages/store-cloudflare/src/kv.ts +++ b/packages/store-cloudflare/src/kv.ts @@ -1,27 +1,5 @@ import type { KeyValueStore } from '@chatcops/core'; -import type { D1Database, KVNamespace } from './types.js'; - -export class WorkersKvStore implements KeyValueStore { - constructor(private readonly kv: KVNamespace) {} - - async get(key: string): Promise { - const text = await this.kv.get(key, 'text'); - if (typeof text !== 'string') return null; - return JSON.parse(text) as T; - } - - async set(key: string, value: unknown, options: { ttlMs?: number } = {}): Promise { - const expirationTtl = - options.ttlMs !== undefined - ? Math.max(60, Math.ceil(options.ttlMs / 1000)) - : undefined; - await this.kv.put(key, JSON.stringify(value), expirationTtl ? { expirationTtl } : undefined); - } - - async delete(key: string): Promise { - await this.kv.delete(key); - } -} +import type { D1Database } from './types.js'; export class D1KvStore implements KeyValueStore { constructor( diff --git a/packages/store-cloudflare/src/row-hook.ts b/packages/store-cloudflare/src/row-hook.ts new file mode 100644 index 00000000..b055e4aa --- /dev/null +++ b/packages/store-cloudflare/src/row-hook.ts @@ -0,0 +1,64 @@ +import type { ChatMessage } from '@chatcops/core'; + +/** What the hook gets for each new row. */ +export interface RowHookContext { + table: 'conversations' | 'messages'; + conversationId: string; + /** The metadata passed with this write (`append` options or the saved conversation). */ + metadata?: Record; + /** The message being inserted, for the messages table. */ + message?: ChatMessage; +} + +export type RowHookFunction = (context: RowHookContext) => Record; + +/** + * Columns you've added to the conversations and messages tables, by name. + * The `{ name: type }` form from `@chatcops/store-postgres` works too, so one + * config fits every store; D1 stores each value with its own type, so the + * type is ignored here. + */ +export type ExtraColumns = string[] | Record; + +const IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/; +const RESERVED = new Set(['id', 'metadata', 'version', 'created_at', 'updated_at', 'conversation_id', 'seq', 'message']); + +/** A checked hook: the columns it may set, and a function that refuses any others. */ +export interface RowHook { + columns: string[]; + /** The values for `columns`, in order, ready to bind. */ + values(context: RowHookContext): unknown[]; +} + +export function checkRowHook(extraColumns: ExtraColumns | undefined, rowHook: RowHookFunction | undefined): RowHook | undefined { + if (!rowHook && !extraColumns) return undefined; + if (!rowHook || !extraColumns) throw new Error('cloudflareStorage needs both extraColumns and rowHook, or neither.'); + + const columns = Array.isArray(extraColumns) ? extraColumns : Object.keys(extraColumns); + for (const name of columns) { + if (!IDENTIFIER.test(name)) throw new Error(`extraColumns: "${name}" isn't a plain lowercase column name.`); + if (RESERVED.has(name)) throw new Error(`extraColumns: "${name}" is one of the store's own columns.`); + } + const allowed = new Set(columns); + + return { + columns, + values(context) { + const row = rowHook(context) ?? {}; + for (const key of Object.keys(row)) { + if (!allowed.has(key)) { + throw new Error(`rowHook returned "${key}", which isn't in extraColumns. Declare it there first.`); + } + } + return columns.map((name) => bindable(name, row[name])); + }, + }; +} + +/** D1 binds strings, numbers and null. Booleans are stored as the integers 1 and 0. */ +function bindable(name: string, value: unknown): unknown { + if (value === undefined || value === null) return null; + if (typeof value === 'boolean') return value ? 1 : 0; + if (typeof value === 'string' || typeof value === 'number') return value; + throw new Error(`rowHook returned an unsupported value (${typeof value}) for "${name}". Use a string, number, boolean or null.`); +} diff --git a/packages/store-cloudflare/src/types.ts b/packages/store-cloudflare/src/types.ts index cfccee7c..050584ea 100644 --- a/packages/store-cloudflare/src/types.ts +++ b/packages/store-cloudflare/src/types.ts @@ -33,20 +33,6 @@ export interface D1ExecResult { duration: number; } -export interface KVNamespace { - get(key: string, type?: 'text' | 'json' | 'arrayBuffer' | 'stream'): Promise; - put( - key: string, - value: string | ArrayBuffer | ArrayBufferView | ReadableStream, - options?: { - expiration?: number; - expirationTtl?: number; - metadata?: unknown; - } - ): Promise; - delete(key: string): Promise; -} - export interface DurableObjectId { toString(): string; equals(other: DurableObjectId): boolean; diff --git a/packages/store-cloudflare/tests/cloudflare.test.ts b/packages/store-cloudflare/tests/cloudflare.test.ts index beef8461..7bb2eb04 100644 --- a/packages/store-cloudflare/tests/cloudflare.test.ts +++ b/packages/store-cloudflare/tests/cloudflare.test.ts @@ -80,9 +80,8 @@ describe('cloudflare storage options and edge cases', () => { await expect(storage.close?.()).resolves.toBeUndefined(); }); - it('supports KV set without ttl and KV delete', async () => { - const { kv } = await getMiniflareBindings(); - const storage = cloudflareStorage({ d1: (await getMiniflareBindings()).d1, kv }); + it('supports key-value set without ttl and delete', async () => { + const storage = cloudflareStorage({ d1: (await getMiniflareBindings()).d1 }); await storage.kv?.set('no-ttl', { foo: 'bar' }); expect(await storage.kv?.get('no-ttl')).toEqual({ foo: 'bar' }); await storage.kv?.delete('no-ttl'); diff --git a/packages/store-cloudflare/tests/conformance.test.ts b/packages/store-cloudflare/tests/conformance.test.ts index abbc560a..93313688 100644 --- a/packages/store-cloudflare/tests/conformance.test.ts +++ b/packages/store-cloudflare/tests/conformance.test.ts @@ -4,12 +4,11 @@ import { cloudflareStorage } from '../src/index.js'; import { disposeMiniflare, getMiniflareBindings } from './miniflare.js'; runStorageConformance( - 'cloudflare (D1 + KV + DO)', + 'cloudflare (D1 + DO)', async () => { - const { d1, kv, counters } = await getMiniflareBindings(); - return cloudflareStorage({ d1, kv, counters }); - }, - { skip: ['kv-ttl'] } + const { d1, counters } = await getMiniflareBindings(); + return cloudflareStorage({ d1, counters }); + } ); runStorageConformance( diff --git a/packages/store-cloudflare/tests/miniflare.ts b/packages/store-cloudflare/tests/miniflare.ts index 44d33c17..c62c8af2 100644 --- a/packages/store-cloudflare/tests/miniflare.ts +++ b/packages/store-cloudflare/tests/miniflare.ts @@ -1,10 +1,9 @@ import { readFileSync } from 'node:fs'; import { Miniflare } from 'miniflare'; -import type { ChatCopsCounter, D1Database, DurableObjectNamespace, KVNamespace } from '../src/index.js'; +import type { ChatCopsCounter, D1Database, DurableObjectNamespace } from '../src/index.js'; let mf: Miniflare | undefined; let d1Instance: D1Database | undefined; -let kvInstance: KVNamespace | undefined; let countersInstance: DurableObjectNamespace | undefined; export async function getMiniflareBindings() { @@ -34,7 +33,6 @@ export async function getMiniflareBindings() { }, ], d1Databases: ['DB'], - kvNamespaces: ['KV'], durableObjects: { COUNTERS: { className: 'ChatCopsCounter', @@ -45,13 +43,12 @@ export async function getMiniflareBindings() { }); await mf.ready; d1Instance = (await mf.getD1Database('DB')) as unknown as D1Database; - kvInstance = (await mf.getKVNamespace('KV')) as unknown as KVNamespace; countersInstance = (await mf.getDurableObjectNamespace('COUNTERS')) as unknown as DurableObjectNamespace; } - if (!d1Instance || !kvInstance || !countersInstance) { + if (!d1Instance || !countersInstance) { throw new Error('Failed to initialize Miniflare bindings'); } - return { mf, d1: d1Instance, kv: kvInstance, counters: countersInstance }; + return { mf, d1: d1Instance, counters: countersInstance }; } export async function disposeMiniflare() { diff --git a/packages/store-cloudflare/tests/row-hook.test.ts b/packages/store-cloudflare/tests/row-hook.test.ts new file mode 100644 index 00000000..5de151b5 --- /dev/null +++ b/packages/store-cloudflare/tests/row-hook.test.ts @@ -0,0 +1,96 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { cloudflareStorage, type D1Database, type RowHookFunction } from '../src/index.js'; +import { disposeMiniflare, getMiniflareBindings } from './miniflare.js'; + +const workspace = '6f1c2a5e-8f2b-4d1a-9c3e-2b7a1d4e5f60'; +let d1: D1Database; + +beforeAll(async () => { + d1 = (await getMiniflareBindings()).d1; + // Creates the tables, then adds the columns the cloud's own migration adds. + await cloudflareStorage({ d1 }).conversations.get('warm-up'); + for (const table of ['chatcops_conversations', 'chatcops_messages']) { + await d1.prepare(`ALTER TABLE ${table} ADD COLUMN workspace_id TEXT`).run(); + await d1.prepare(`ALTER TABLE ${table} ADD COLUMN site_id TEXT`).run(); + await d1.prepare(`ALTER TABLE ${table} ADD COLUMN trial INTEGER`).run(); + } +}); + +afterAll(async () => { + await disposeMiniflare(); +}); + +function setup(rowHook: RowHookFunction = ({ metadata }) => ({ workspace_id: metadata?.workspaceId, site_id: metadata?.siteId })) { + const hook = vi.fn(rowHook); + const store = cloudflareStorage({ d1, extraColumns: { workspace_id: 'uuid', site_id: 'text' }, rowHook: hook }).conversations; + return { store, hook }; +} + +async function rows(sql: string) { + return (await d1.prepare(sql).all>()).results; +} + +describe('rowHook and extraColumns', () => { + it('stamps new conversation and message rows from the metadata', async () => { + const { store, hook } = setup(); + await store.append?.('c1', [{ id: 'u1', role: 'user', content: 'Hi', timestamp: 1 }], { + metadata: { workspaceId: workspace, siteId: 'site-1' }, + }); + + expect(await rows(`SELECT workspace_id, site_id FROM chatcops_conversations WHERE id = 'c1'`)).toEqual([{ workspace_id: workspace, site_id: 'site-1' }]); + expect(await rows(`SELECT workspace_id, site_id FROM chatcops_messages WHERE conversation_id = 'c1'`)).toEqual([{ workspace_id: workspace, site_id: 'site-1' }]); + expect(hook).toHaveBeenCalledWith(expect.objectContaining({ table: 'messages', conversationId: 'c1', message: expect.objectContaining({ id: 'u1' }) })); + }); + + it('only stamps on insert: a later append or save leaves the conversation row alone', async () => { + const { store } = setup(); + await store.append?.('c2', [{ id: 'u1', role: 'user', content: 'Hi', timestamp: 1 }], { metadata: { workspaceId: workspace, siteId: 'first' } }); + await store.append?.('c2', [{ id: 'u2', role: 'user', content: 'Again', timestamp: 2 }], { metadata: { siteId: 'second' } }); + expect(await rows(`SELECT site_id FROM chatcops_conversations WHERE id = 'c2'`)).toEqual([{ site_id: 'first' }]); + expect(await rows(`SELECT site_id FROM chatcops_messages WHERE conversation_id = 'c2' ORDER BY seq`)).toEqual([{ site_id: 'first' }, { site_id: 'second' }]); + + const conversation = await store.get('c2'); + if (!conversation) throw new Error('c2 was not stored'); + await store.save({ ...conversation, metadata: { siteId: 'third' } }); + expect(await rows(`SELECT site_id FROM chatcops_conversations WHERE id = 'c2'`)).toEqual([{ site_id: 'first' }]); + }); + + it('binds values as parameters, never as SQL, and stores booleans as 1 or 0', async () => { + const store = cloudflareStorage({ + d1, + extraColumns: ['site_id', 'trial'], + rowHook: ({ metadata }) => ({ site_id: metadata?.siteId, trial: true }), + }).conversations; + const sneaky = "x'); DROP TABLE chatcops_messages; --"; + await store.save({ id: 'c3', messages: [{ id: 'u1', role: 'user', content: 'Hi', timestamp: 1 }], metadata: { siteId: sneaky }, createdAt: 1, updatedAt: 1 }); + expect(await rows(`SELECT site_id, trial FROM chatcops_messages WHERE conversation_id = 'c3'`)).toEqual([{ site_id: sneaky, trial: 1 }]); + }); + + it('refuses a key that is not declared, and stores nothing from that write', async () => { + const { store } = setup(() => ({ tenant: 'x' })); + await expect(store.append?.('c4', [{ id: 'u1', role: 'user', content: 'Hi', timestamp: 1 }])).rejects.toThrow( + 'rowHook returned "tenant", which isn\'t in extraColumns' + ); + expect(await store.get('c4')).toBeNull(); + }); + + it('refuses a value D1 cannot store', async () => { + const { store } = setup(() => ({ site_id: { nested: true } })); + await expect(store.append?.('c5', [{ id: 'u1', role: 'user', content: 'Hi', timestamp: 1 }])).rejects.toThrow( + 'rowHook returned an unsupported value (object) for "site_id"' + ); + }); +}); + +describe('extraColumns checks', () => { + it('needs both options or neither', () => { + expect(() => cloudflareStorage({ d1, extraColumns: ['a'] })).toThrow('both extraColumns and rowHook'); + expect(() => cloudflareStorage({ d1, rowHook: () => ({}) })).toThrow('both extraColumns and rowHook'); + }); + + it("only takes plain column names, and not the store's own columns", () => { + const rowHook = () => ({}); + expect(() => cloudflareStorage({ d1, extraColumns: ['bad name'], rowHook })).toThrow('plain lowercase column name'); + expect(() => cloudflareStorage({ d1, extraColumns: ['message'], rowHook })).toThrow("store's own columns"); + }); +}); From 7c27f8ceb00036649066abddcc4385d843549737 Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:25:20 +0530 Subject: [PATCH 76/80] feat(server): write analytics to the storage event sink (#316) --- .changeset/server-analytics-sink.md | 5 + packages/server/src/adapters/express.ts | 28 +++- packages/server/src/handler.ts | 41 ++++- .../server/tests/adapters/express.test.ts | 18 +-- packages/server/tests/analytics-sink.test.ts | 151 ++++++++++++++++++ website/src/content/docs/core/analytics.mdx | 14 +- 6 files changed, 238 insertions(+), 19 deletions(-) create mode 100644 .changeset/server-analytics-sink.md create mode 100644 packages/server/tests/analytics-sink.test.ts diff --git a/.changeset/server-analytics-sink.md b/.changeset/server-analytics-sink.md new file mode 100644 index 00000000..6cceadea --- /dev/null +++ b/.changeset/server-analytics-sink.md @@ -0,0 +1,5 @@ +--- +"@chatcops/server": minor +--- + +With `analytics: true` and a `storage` that has an event sink, the server writes analytics events there too, buffered with `BufferedEventSink` and flushed before each reply's stream ends, so serverless runtimes don't drop them. `chatcopsAnalyticsHandler` then counts from the store, so the stats cover every instance, for `?from=` and `?to=` in milliseconds (default: the last 30 days). The handler's new `analyticsStats(range)` does the same; `getAnalytics()` still describes this process only. diff --git a/packages/server/src/adapters/express.ts b/packages/server/src/adapters/express.ts index e4214f0d..b683a462 100644 --- a/packages/server/src/adapters/express.ts +++ b/packages/server/src/adapters/express.ts @@ -107,11 +107,15 @@ function constantTimeEqual(a: string, b: string): boolean { return mismatch === 0; } +/** + * Analytics stats as JSON. With a storage that has an event sink, they cover + * every instance, for `?from=` and `?to=` (milliseconds) or the last 30 days. + */ export function chatcopsAnalyticsHandler(config: ChatCopsServerConfig) { - const { getAnalytics } = getOrCreateChatHandler(config); + const { analyticsStats } = getOrCreateChatHandler(config); let warned = false; - return (req: Request, res: Response) => { + return async (req: Request, res: Response) => { if (config.analyticsToken) { const auth = req.headers?.authorization; const token = typeof auth === 'string' && auth.startsWith('Bearer ') ? auth.slice(7) : undefined; @@ -128,6 +132,24 @@ export function chatcopsAnalyticsHandler(config: ChatCopsServerConfig) { ); } - res.json(getAnalytics()); + const from = timeParam(req.query?.from); + const to = timeParam(req.query?.to); + if (from === null || to === null) { + res.status(400).json({ error: 'invalid_range' }); + return; + } + try { + res.json(await analyticsStats({ from, to })); + } catch (err) { + console.error('[chatcops] Reading analytics failed:', err instanceof Error ? err.message : String(err)); + res.status(500).json({ error: 'internal_error' }); + } }; } + +/** A `from` or `to` query value: unset, a time in milliseconds, or `null` when it isn't one. */ +function timeParam(value: unknown): number | undefined | null { + if (value === undefined) return undefined; + const time = typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN; + return Number.isFinite(time) ? time : null; +} diff --git a/packages/server/src/handler.ts b/packages/server/src/handler.ts index fe2de5a9..aa46b8bc 100644 --- a/packages/server/src/handler.ts +++ b/packages/server/src/handler.ts @@ -3,6 +3,9 @@ import { isAIProvider, ConversationManager, AnalyticsCollector, + BufferedEventSink, + statsFromSink, + type AnalyticsStats, createBudgetGuard, withDefaultBudgets, createRateLimiter, @@ -74,7 +77,19 @@ export function createChatHandler(config: ChatCopsServerConfig) { limit: rateLimit.maxRequests, windowMs: rateLimit.windowMs, }); - const analytics = config.analytics ? new AnalyticsCollector() : null; + // Events always count in this process, and go to the storage's event sink + // too when it has one, so stats can cover every instance. + const collector = config.analytics ? new AnalyticsCollector() : null; + const eventSink = config.analytics ? storage?.events : undefined; + const bufferedEvents = eventSink ? new BufferedEventSink(eventSink) : undefined; + const analytics = collector + ? { + track(type: string, data?: Record) { + collector.track(type, data); + bufferedEvents?.track(type, data); + }, + } + : null; const webhooks = config.webhooks?.length ? new WebhookDispatcher(config.webhooks) : null; const toolDefs = config.tools?.map(toolToDefinition) ?? []; // A broken tool definition is a code mistake, so fail here, at startup, @@ -159,9 +174,25 @@ export function createChatHandler(config: ChatCopsServerConfig) { } finally { await release(); } - // Keeping the stream open until lead webhooks finish keeps serverless - // runtimes from cutting them off once the response ends. + // Keeping the stream open until lead webhooks and analytics writes + // finish keeps serverless runtimes from cutting them off once the + // response ends. A failed analytics write is logged, never thrown. await awaitPendingWebhooks(pendingWebhooks); + await bufferedEvents?.flush(); + } + + /** + * Analytics stats for `range` (default: the last 30 days), counted in the + * storage's event sink when it can be queried, so they cover every + * instance. Otherwise they come from this process only, as + * `getAnalytics()` does. `null` when `analytics` is off. + */ + async function analyticsStats(range?: { from?: number; to?: number }): Promise { + if (!collector) return null; + if (!bufferedEvents || !eventSink?.query) return collector.getStats(); + await bufferedEvents.flush(); + const to = range?.to ?? Date.now() + 1; + return statsFromSink(eventSink, { from: range?.from ?? to - 30 * DAY_MS, to }); } /** @@ -391,7 +422,9 @@ export function createChatHandler(config: ChatCopsServerConfig) { ? (body: unknown, origin: string | null, clientIp?: string) => sessions.mint(body, origin, clientIp) : undefined, protocolStats: () => ({ ...requests }), - getAnalytics: () => analytics?.getStats() ?? null, + /** Stats from this process only. `analyticsStats()` reads the storage's event sink. */ + getAnalytics: () => collector?.getStats() ?? null, + analyticsStats, cleanup, }; } diff --git a/packages/server/tests/adapters/express.test.ts b/packages/server/tests/adapters/express.test.ts index c0dbeed8..4c08160a 100644 --- a/packages/server/tests/adapters/express.test.ts +++ b/packages/server/tests/adapters/express.test.ts @@ -355,14 +355,14 @@ describe('express analytics sharing', () => { } const { fake: statsFake, res: statsRes } = createResponse(); - chatcopsAnalyticsHandler(config)(createRequest('GET'), statsRes); + await chatcopsAnalyticsHandler(config)(createRequest('GET'), statsRes); expect(statsFake.body).toMatchObject({ totalConversations: 2 }); // One handler for the config, shared by the middleware and the stats route. expect(createChatHandler).toHaveBeenCalledTimes(1); }); - it('warns once across several requests when analyticsToken is not configured', () => { + it('warns once across several requests when analyticsToken is not configured', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const config: ChatCopsServerConfig = { provider: { type: 'claude', apiKey: 'test-key' }, @@ -372,11 +372,11 @@ describe('express analytics sharing', () => { const handler = chatcopsAnalyticsHandler(config); const { fake: firstFake, res: firstRes } = createResponse(); - handler(createRequest('GET'), firstRes); + await handler(createRequest('GET'), firstRes); expect(firstFake.statusCode).toBe(200); const { fake: secondFake, res: secondRes } = createResponse(); - handler(createRequest('GET'), secondRes); + await handler(createRequest('GET'), secondRes); expect(secondFake.statusCode).toBe(200); expect(warnSpy).toHaveBeenCalledTimes(1); @@ -385,7 +385,7 @@ describe('express analytics sharing', () => { ); }); - it('requires a bearer token when analyticsToken is configured', () => { + it('requires a bearer token when analyticsToken is configured', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const config: ChatCopsServerConfig = { provider: { type: 'claude', apiKey: 'test-key' }, @@ -397,28 +397,28 @@ describe('express analytics sharing', () => { // Missing token const { fake: missingFake, res: missingRes } = createResponse(); - handler(createRequest('GET'), missingRes); + await handler(createRequest('GET'), missingRes); expect(missingFake.statusCode).toBe(401); expect(missingFake.headers['WWW-Authenticate']).toBe('Bearer'); expect(missingFake.body).toEqual({ error: 'unauthorized' }); // Wrong token of different length const { fake: diffLengthFake, res: diffLengthRes } = createResponse(); - handler(createRequest('GET', undefined, { authorization: 'Bearer short' }), diffLengthRes); + await handler(createRequest('GET', undefined, { authorization: 'Bearer short' }), diffLengthRes); expect(diffLengthFake.statusCode).toBe(401); expect(diffLengthFake.headers['WWW-Authenticate']).toBe('Bearer'); expect(diffLengthFake.body).toEqual({ error: 'unauthorized' }); // Wrong token of same length const { fake: wrongFake, res: wrongRes } = createResponse(); - handler(createRequest('GET', undefined, { authorization: 'Bearer secret-tokeX' }), wrongRes); + await handler(createRequest('GET', undefined, { authorization: 'Bearer secret-tokeX' }), wrongRes); expect(wrongFake.statusCode).toBe(401); expect(wrongFake.headers['WWW-Authenticate']).toBe('Bearer'); expect(wrongFake.body).toEqual({ error: 'unauthorized' }); // Right token const { fake: rightFake, res: rightRes } = createResponse(); - handler(createRequest('GET', undefined, { authorization: 'Bearer secret-token' }), rightRes); + await handler(createRequest('GET', undefined, { authorization: 'Bearer secret-token' }), rightRes); expect(rightFake.statusCode).toBe(200); expect(rightFake.body).toMatchObject({ totalConversations: 0 }); diff --git a/packages/server/tests/analytics-sink.test.ts b/packages/server/tests/analytics-sink.test.ts new file mode 100644 index 00000000..c72d9948 --- /dev/null +++ b/packages/server/tests/analytics-sink.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Request as ExpressRequest, Response as ExpressResponse } from 'express'; +import { memoryStorage, type AnalyticsEvent, type ChatCopsStorage, type EventSink } from '@chatcops/core'; +import { scriptedProvider, textStep } from '@chatcops/core/testing/provider'; +import { createChatHandler } from '../src/handler.js'; +import { chatcopsAnalyticsHandler } from '../src/adapters/express.js'; +import type { ChatCopsServerConfig } from '../src/config.js'; + +function config(storage: ChatCopsStorage, overrides: Partial = {}): ChatCopsServerConfig { + return { + provider: scriptedProvider(Array.from({ length: 5 }, () => textStep('Hello'))), + systemPrompt: 'Test', + budgets: false, + analytics: true, + storage, + ...overrides, + }; +} + +/** Sends one message and reads the reply to the end. */ +async function chat(handler: ReturnType, conversationId: string) { + for await (const _ of handler.handleChat({ conversationId, message: 'Hi' })) { + // drain + } +} + +const DAY = 86_400_000; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('analytics on the storage event sink', () => { + it('writes the events by the time the reply ends, and counts them across instances', async () => { + const storage = memoryStorage(); + // Two server instances sharing one store, as on serverless. + const first = createChatHandler(config(storage)); + const second = createChatHandler(config(storage)); + + await chat(first, 'c1'); + await chat(second, 'c2'); + + const now = Date.now(); + const written = await storage.events?.query?.({ from: now - DAY, to: now + 1 }); + expect(written).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'conversation:started', count: 2 }), + expect.objectContaining({ type: 'message:sent', count: 2 }), + expect.objectContaining({ type: 'message:received', count: 2 }), + ]) + ); + + expect(await first.analyticsStats()).toMatchObject({ totalConversations: 2, totalMessages: 4 }); + // getAnalytics() still describes this process only. + expect(first.getAnalytics()).toMatchObject({ totalConversations: 1, totalMessages: 2 }); + }); + + it('counts only the range asked for', async () => { + const storage = memoryStorage(); + const handler = createChatHandler(config(storage)); + await chat(handler, 'c1'); + + const now = Date.now(); + expect(await handler.analyticsStats({ from: now + DAY, to: now + 2 * DAY })).toMatchObject({ totalConversations: 0 }); + expect(await handler.analyticsStats({ from: now - DAY })).toMatchObject({ totalConversations: 1 }); + }); + + it('falls back to this process for a sink that can only be written to', async () => { + const write = vi.fn(async (_events: AnalyticsEvent[]) => undefined); + const events: EventSink = { write }; + const handler = createChatHandler(config({ ...memoryStorage(), events })); + await chat(handler, 'c1'); + + expect(write).toHaveBeenCalled(); + expect(write.mock.calls.flatMap(([batch]) => batch.map((event) => event.type))).toEqual(['conversation:started', 'message:sent', 'message:received']); + expect(await handler.analyticsStats()).toMatchObject({ totalConversations: 1 }); + }); + + it('still answers when the sink fails, and logs it', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const events: EventSink = { write: async () => Promise.reject(new Error('db down')) }; + const handler = createChatHandler(config({ ...memoryStorage(), events })); + + const chunks: string[] = []; + for await (const chunk of handler.handleChat({ conversationId: 'c1', message: 'Hi' })) chunks.push(chunk); + + expect(chunks.join('')).toContain('Hello'); + expect(error).toHaveBeenCalledWith('[chatcops] analytics write failed: db down'); + }); + + it('writes nothing with analytics off', async () => { + const storage = memoryStorage(); + const write = vi.spyOn(storage.events as EventSink, 'write'); + const handler = createChatHandler(config(storage, { analytics: false })); + await chat(handler, 'c1'); + + expect(write).not.toHaveBeenCalled(); + expect(await handler.analyticsStats()).toBeNull(); + }); +}); + +describe('the Express analytics route', () => { + function response() { + const res = { + statusCode: 200, + body: undefined as unknown, + setHeader: vi.fn(), + status(code: number) { + this.statusCode = code; + return this; + }, + json(value: unknown) { + this.body = value; + }, + }; + return res; + } + const get = (query: Record = {}) => + ({ method: 'GET', headers: { authorization: 'Bearer token' }, query }) as unknown as ExpressRequest; + + it('reads the sink for the range in the query', async () => { + const storage = memoryStorage(); + const serverConfig = config(storage, { analyticsToken: 'token' }); + await chat(createChatHandler(serverConfig), 'other-instance'); + + const res = response(); + await chatcopsAnalyticsHandler(serverConfig)(get({ from: String(Date.now() - DAY) }), res as unknown as ExpressResponse); + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ totalConversations: 1 }); + + const empty = response(); + await chatcopsAnalyticsHandler(serverConfig)(get({ from: String(Date.now() + DAY) }), empty as unknown as ExpressResponse); + expect(empty.body).toMatchObject({ totalConversations: 0 }); + }); + + it('refuses a range that is not a time in milliseconds', async () => { + const res = response(); + await chatcopsAnalyticsHandler(config(memoryStorage(), { analyticsToken: 'token' }))(get({ from: 'yesterday' }), res as unknown as ExpressResponse); + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: 'invalid_range' }); + }); + + it('answers 500 when the sink cannot be read', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const events: EventSink = { write: async () => undefined, query: async () => Promise.reject(new Error('db down')) }; + const res = response(); + await chatcopsAnalyticsHandler(config({ ...memoryStorage(), events }, { analyticsToken: 'token' }))(get(), res as unknown as ExpressResponse); + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: 'internal_error' }); + }); +}); diff --git a/website/src/content/docs/core/analytics.mdx b/website/src/content/docs/core/analytics.mdx index 249de209..bc7d9eed 100644 --- a/website/src/content/docs/core/analytics.mdx +++ b/website/src/content/docs/core/analytics.mdx @@ -17,7 +17,7 @@ chatcopsMiddleware({ ## Reading Stats with Express -`chatcopsAnalyticsHandler` returns the stats for the traffic handled by `chatcopsMiddleware`. Pass the **same config object** to both so they share one handler and one `AnalyticsCollector`: +`chatcopsAnalyticsHandler` returns the stats for the traffic handled by `chatcopsMiddleware`. Pass the **same config object** to both so they share one handler: ```typescript import express from 'express'; @@ -34,7 +34,7 @@ const config = { const app = express(); app.use(express.json()); -app.all('/chat', chatcopsMiddleware(config)); +app.use('/chat', chatcopsMiddleware(config)); app.get('/analytics', chatcopsAnalyticsHandler(config)); app.listen(3001); @@ -48,6 +48,14 @@ curl -H "Authorization: Bearer " http://localhost:3001/analytics When unset, the endpoint is public and logs a warning the first time it is hit. +With a `storage` that has an event sink (`memoryStorage()`, Postgres, Redis, SQLite and Cloudflare stores all do), the server writes every event there too, and the endpoint counts from the store. Then the stats cover every server instance, not just the one answering. Pick the range with `from` and `to` in milliseconds. The default is the last 30 days: + +```bash +curl -H "Authorization: Bearer " "http://localhost:3001/analytics?from=1792368000000" +``` + +Without a store that can be queried, the stats come from the memory of the process that answers, which on serverless is one instance at a time. A range that isn't a number gets `400 {"error":"invalid_range"}`. + `GET /analytics` responds with the aggregated stats (or `null` if `analytics` is not enabled): ```json @@ -119,7 +127,7 @@ interface AnalyticsEvent { ## Storage -The built-in collector stores events in memory (max 1000 events). To write events to a storage-backed `EventSink`, use `BufferedEventSink`. It batches up to 50 events by default and flushes every 2 seconds. Call `flush()` when you need to wait for queued writes, such as before a process exits: +The built-in collector stores events in memory (max 1000 events). With `analytics: true` and a `storage`, the server already writes events to the store's sink, buffered and flushed as each reply ends. To do the same in your own code, use `BufferedEventSink`. It batches up to 50 events by default and flushes every 2 seconds. Call `flush()` when you need to wait for queued writes, such as before a process exits: ```typescript import { BufferedEventSink, memoryStorage, statsFromSink } from '@chatcops/core'; From dbe170663cfe314a5780ace51e0aeb412966d04a Mon Sep 17 00:00:00 2001 From: kachamsiddarth <86518485+kachamsiddarth@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:29:06 +0530 Subject: [PATCH 77/80] docs(server): document running purge() on a schedule (#313) * docs: add scheduled purge recipes * docs(server): address purge scheduling review feedback --------- Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> --- .changeset/petite-experts-march.md | 2 + website/src/content/docs/server/storage.mdx | 239 ++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 .changeset/petite-experts-march.md diff --git a/.changeset/petite-experts-march.md b/.changeset/petite-experts-march.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/petite-experts-march.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/website/src/content/docs/server/storage.mdx b/website/src/content/docs/server/storage.mdx index 28d3bcc1..b823c25c 100644 --- a/website/src/content/docs/server/storage.mdx +++ b/website/src/content/docs/server/storage.mdx @@ -189,3 +189,242 @@ if (typeof deleted === 'number') { console.log(`Purged ${deleted} old conversations`); } ``` + +`purge()` returns `null` only when a custom conversation store does not implement the optional `purge` capability, such as a custom store with only `get`, `save`, and `delete`. The built-in memory, SQLite, Postgres, and Redis stores all implement `purge()`. + +### Express / Node.js + +Use [`node-cron`](https://github.com/node-cron/node-cron) to call `purge()` on a schedule from the same process as your Express server: + +```bash +npm install node-cron pg @types/pg +``` + +```typescript +import express from 'express'; +import cron from 'node-cron'; +import { Pool } from 'pg'; +import { chatcopsMiddleware, createChatHandler, type ChatCopsServerConfig } from '@chatcops/server'; +import { postgresStorage } from '@chatcops/store-postgres'; + +const db = new Pool({ connectionString: process.env.DATABASE_URL }); +const storage = postgresStorage({ db }); + +const config: ChatCopsServerConfig = { + provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY! }, + systemPrompt: 'You are a helpful assistant.', + storage, + retention: { conversationsDays: 30 }, +}; + +const handler = createChatHandler(config); + +const app = express(); +app.use(express.json()); +app.use('/api/chat', chatcopsMiddleware(config)); + +// Run at 03:00 every day. +cron.schedule('0 3 * * *', async () => { + try { + const deleted = await handler.purge(); + if (typeof deleted === 'number') { + console.log(`[chatcops] Purged ${deleted} old conversations`); + } else { + console.warn('[chatcops] purge() returned null: store does not support purge'); + } + } catch (err) { + console.error('[chatcops] Scheduled purge failed:', err); + } +}); +``` + +Alternatively, keep the purge logic in a standalone script and invoke it from an external scheduler (systemd timer, AWS EventBridge, GitHub Actions cron, etc.). Run database migrations separately as part of your deployment, not on every cleanup run: + +```typescript +// scripts/purge.ts +import { createChatHandler } from '@chatcops/server'; +import { Pool } from 'pg'; +import { postgresStorage } from '@chatcops/store-postgres'; + +const db = new Pool({ connectionString: process.env.DATABASE_URL }); + +try { + const handler = createChatHandler({ + provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY! }, + systemPrompt: '', + storage: postgresStorage({ db }), + retention: { conversationsDays: 30 }, + }); + + const deleted = await handler.purge(); + if (typeof deleted === 'number') { + console.log(`Purged ${deleted} old conversations`); + } else { + console.warn('purge() returned null: store does not support purge'); + } +} catch (err) { + console.error('Purge failed:', err); + process.exitCode = 1; +} finally { + await db.end(); +} +``` + +Run it with `tsx`: + +```bash +npx tsx scripts/purge.ts +``` + +### Vercel Cron Jobs + +Vercel Cron Jobs call an API route on a schedule. Create a route that runs `purge()` and protect it with a shared secret so only Vercel's cron runner can trigger it: + +```typescript +// app/api/purge/route.ts (Next.js App Router) +import { NextRequest, NextResponse } from 'next/server'; +// Import the createChatHandler instance from your application module. +// Replace '@/lib/chatcops' with the actual path where your handler is created, +// configured with the same storage and retention settings as your chat route. +import { handler } from '@/lib/chatcops'; + +export const runtime = 'nodejs'; + +export async function GET(req: NextRequest) { + const secret = process.env.CRON_SECRET; + if (!secret) { + return NextResponse.json({ error: 'CRON_SECRET is not configured' }, { status: 500 }); + } + const auth = req.headers.get('authorization'); + if (auth !== `Bearer ${secret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const deleted = await handler.purge(); + if (typeof deleted === 'number') { + return NextResponse.json({ deleted }); + } + return NextResponse.json({ deleted: null, message: 'Store does not support purge' }); + } catch (err) { + console.error('[chatcops] Purge failed:', err); + return NextResponse.json({ error: 'Purge failed' }, { status: 500 }); + } +} +``` + +Register the cron trigger in `vercel.json`. The path must match the route above: + +```json +{ + "crons": [ + { + "path": "/api/purge", + "schedule": "0 3 * * *" + } + ] +} +``` + +Vercel automatically sends an `Authorization: Bearer ` header when it invokes the route. Set `CRON_SECRET` in your Vercel project's environment variables. The route rejects any caller whose header doesn't match, and returns 500 when the secret is not configured at all. + +:::note +Vercel Cron Jobs are available on all plans. On the Hobby plan, each cron job is limited to running at most once per day, which is sufficient for daily retention cleanup. +::: + +### Cloudflare Workers + +Add a `scheduled` export to your Worker. Cloudflare calls it on the cron triggers you define in `wrangler.toml`. + +This example uses [`@chatcops/store-redis`](/server/storage/#redis) with Upstash's HTTP-based Redis client because it runs directly in Workers over HTTP without extra database-connection setup: + +```bash +npm install @chatcops/store-redis @upstash/redis +``` + +Create a fresh handler inside `scheduled()` using the invocation's `env` so that no environment-specific secret is cached at global scope: + +```typescript +// src/index.ts +import { chatcopsCloudflareHandler, createChatHandler } from '@chatcops/server'; +import { Redis } from '@upstash/redis/cloudflare'; +import { redisStorage } from '@chatcops/store-redis'; + +interface Env { + ANTHROPIC_API_KEY: string; + UPSTASH_REDIS_REST_URL: string; + UPSTASH_REDIS_REST_TOKEN: string; +} + +function makeStorage(env: Env) { + const redis = new Redis({ + url: env.UPSTASH_REDIS_REST_URL, + token: env.UPSTASH_REDIS_REST_TOKEN, + }); + // retention.conversationsDays determines the cutoff used by purge(). + // purge() removes old conversations and their corresponding index entries. + // conversationTtlMs is an optional Redis key-expiration safety net. + return redisStorage({ redis, prefix: 'chatcops' }); +} + +// The fetch handler is built once per isolate; env is the same on every request. +let fetchHandler: ReturnType | undefined; +function getFetchHandler(env: Env) { + fetchHandler ??= chatcopsCloudflareHandler({ + provider: { type: 'claude', apiKey: env.ANTHROPIC_API_KEY }, + systemPrompt: 'You are a helpful assistant.', + storage: makeStorage(env), + retention: { conversationsDays: 30 }, + allowedOrigins: ['*'], + }); + return fetchHandler; +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + return getFetchHandler(env)(request, env, ctx); + }, + + async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext) { + // Build a dedicated handler for this invocation using the current env bindings. + const purgeHandler = createChatHandler({ + provider: { type: 'claude', apiKey: env.ANTHROPIC_API_KEY }, + systemPrompt: '', + storage: makeStorage(env), + retention: { conversationsDays: 30 }, + }); + + ctx.waitUntil( + purgeHandler + .purge() + .then((deleted) => { + if (typeof deleted === 'number') { + console.log(`[chatcops] Purged ${deleted} old conversations`); + } else { + console.warn('[chatcops] purge() returned null: store does not support purge'); + } + }) + .catch((err) => { + console.error('[chatcops] Scheduled purge failed:', err); + }) + ); + }, +}; +``` + +Register the cron trigger in `wrangler.toml`: + +```toml +name = "chatcops-worker" +main = "src/index.ts" +compatibility_date = "2024-01-01" + +[triggers] +crons = ["0 3 * * *"] +``` + +When using a custom conversation store, the store must implement `ConversationStore.purge` for scheduled cleanup to work. `purge()` returns `null` when the store does not implement it, making scheduled cleanup a no-op. + +:::note +The in-memory store implements `purge()`, but each process or Worker instance only purges its own in-memory data. Scheduled cleanup is most useful when the application uses shared, persistent storage. +::: From e4b6f021b7fb337a89804e9693dc1c5f2b5dd7f8 Mon Sep 17 00:00:00 2001 From: Varun-pahuja Date: Fri, 23 Oct 2026 11:23:17 +0530 Subject: [PATCH 78/80] feat(server): add startup security checks for secrets and origins (#317) Adds checkSecuritySettings() in packages/server/src/security/startup-checks.ts and tests. Reports errors/warnings for: - session secrets < 32 bytes (UTF-8) - secret matches NEXT_PUBLIC_, VITE_, PUBLIC_, NUXT_PUBLIC_, EXPO_PUBLIC_, REACT_APP_ env vars - allowed origin not http(s):// or * - origin with path/query/trailing slash - * combined with other origins - duplicate secrets (warn) - http:// origin not localhost/127.0.0.1 (warn) Not exported from @chatcops/server yet. Closes #231 --- .changeset/security-startup-checks.md | 7 + .../server/src/security/startup-checks.ts | 141 ++++++++++++++++ .../tests/security/startup-checks.test.ts | 151 ++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 .changeset/security-startup-checks.md create mode 100644 packages/server/src/security/startup-checks.ts create mode 100644 packages/server/tests/security/startup-checks.test.ts diff --git a/.changeset/security-startup-checks.md b/.changeset/security-startup-checks.md new file mode 100644 index 00000000..b2fded7f --- /dev/null +++ b/.changeset/security-startup-checks.md @@ -0,0 +1,7 @@ +--- +--- + +Add server startup security checks for session secrets and allowed origins. +Not exported from @chatcops/server yet; the checks will be called by the +handler in a follow-up PR. Empty changeset per issue requirement. +--- diff --git a/packages/server/src/security/startup-checks.ts b/packages/server/src/security/startup-checks.ts new file mode 100644 index 00000000..12023d0a --- /dev/null +++ b/packages/server/src/security/startup-checks.ts @@ -0,0 +1,141 @@ +/** + * Startup checks for the security settings the server is about to add: the + * allowed origins and the secrets that sign session tokens. + * + * These are the mistakes that fail silently. A secret that is ten characters + * long works, and so does a secret that is also a `NEXT_PUBLIC_` variable, so + * nothing breaks until the day it does. A function that finds them before the + * server starts is worth more than one that finds them during a request. + * + * Nothing here logs or throws. The caller decides what to do with a problem. + */ + +/** The security settings a server is configured with. */ +export interface SecuritySettings { + /** The origins the server accepts requests from. `*` allows every origin. */ + allowedOrigins?: string[]; + /** The secrets that sign session tokens. */ + sessionSecrets?: string[]; +} + +/** One thing that is wrong with the security settings. */ +export interface SecurityProblem { + /** An error stops the server from starting. A warning is worth a look. */ + level: 'error' | 'warning'; + /** What is wrong, and what to do about it. Never contains a secret. */ + message: string; +} + +/** How many bytes a session secret needs to be worth signing with. */ +const MIN_SECRET_BYTES = 32; + +/** + * The env var prefixes whose value is shipped to the browser, so a secret + * that shares a value with one of them is in the client bundle. + */ +const PUBLIC_ENV_PREFIXES = ['NEXT_PUBLIC_', 'VITE_', 'PUBLIC_', 'NUXT_PUBLIC_', 'EXPO_PUBLIC_', 'REACT_APP_']; + +/** + * The environment to read, which is `process.env` where there is one. Workers + * have no `process`, so the checks still work there with an empty environment. + */ +function defaultEnv(): Record { + return (globalThis as { process?: { env?: Record } }).process?.env ?? {}; +} + +/** How many bytes a string takes in UTF-8, so a 32 byte secret is 32 bytes. */ +function utf8Length(value: string): number { + return new TextEncoder().encode(value).length; +} + +/** + * Finds the mistakes in the security settings, most serious first. + * + * `env` is the environment to look in, and defaults to `process.env`. + */ +export function checkSecuritySettings( + settings: SecuritySettings, + env?: Record, +): SecurityProblem[] { + const problems: SecurityProblem[] = []; + const secrets = settings.sessionSecrets ?? []; + const origins = settings.allowedOrigins ?? []; + const environment = env ?? defaultEnv(); + + for (const [index, secret] of secrets.entries()) { + if (utf8Length(secret) < MIN_SECRET_BYTES) { + problems.push({ + level: 'error', + message: `Session secret ${index + 1} is shorter than ${MIN_SECRET_BYTES} bytes.`, + }); + } + } + + for (const [index, secret] of secrets.entries()) { + for (const name of Object.keys(environment)) { + if (!PUBLIC_ENV_PREFIXES.some((prefix) => name.startsWith(prefix))) continue; + if (environment[name] !== secret) continue; + problems.push({ + level: 'error', + message: + `Session secret ${index + 1} is also the value of the ${name} environment variable, ` + + 'which is sent to the browser. Use a different secret.', + }); + } + } + + for (const [index, secret] of secrets.entries()) { + const first = secrets.indexOf(secret); + if (first !== index) { + problems.push({ + level: 'warning', + message: `Session secrets ${first + 1} and ${index + 1} are the same.`, + }); + } + } + + for (const [index, origin] of origins.entries()) { + if (origin === '*') continue; + if (!origin.startsWith('http://') && !origin.startsWith('https://')) { + problems.push({ + level: 'error', + message: `Allowed origin ${index + 1} must start with http:// or https://.`, + }); + continue; + } + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + problems.push({ + level: 'error', + message: `Allowed origin ${index + 1} is not a valid URL.`, + }); + continue; + } + if (parsed.pathname !== '/' || parsed.search !== '' || origin.endsWith('/')) { + problems.push({ + level: 'error', + message: + `Allowed origin ${index + 1} must not have a path, a query or a trailing slash, ` + + 'because browsers send the origin without one.', + }); + continue; + } + if (origin.startsWith('http://') && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') { + problems.push({ + level: 'warning', + message: `Allowed origin ${index + 1} uses http://. Use https://, unless it is a local development server.`, + }); + } + } + + if (origins.includes('*') && origins.length > 1) { + problems.push({ + level: 'warning', + message: `Allowed origins include "*", so the other ${origins.length - 1} entries do nothing.`, + }); + } + + return problems; +} diff --git a/packages/server/tests/security/startup-checks.test.ts b/packages/server/tests/security/startup-checks.test.ts new file mode 100644 index 00000000..4e94a71a --- /dev/null +++ b/packages/server/tests/security/startup-checks.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest'; +import { checkSecuritySettings } from '../../src/security/startup-checks.js'; + +const LONG_SECRET = 'a-secret-that-is-at-least-32-bytes-long'; + +describe('checkSecuritySettings', () => { + it('says nothing about a clean configuration', () => { + const problems = checkSecuritySettings( + { allowedOrigins: ['https://acme.com'], sessionSecrets: [LONG_SECRET] }, + {}, + ); + expect(problems).toEqual([]); + }); + + it('says nothing when there is nothing to check', () => { + expect(checkSecuritySettings({}, {})).toEqual([]); + }); + + it('reports a session secret shorter than 32 bytes', () => { + const problems = checkSecuritySettings({ sessionSecrets: ['short'] }, {}); + expect(problems).toEqual([ + { level: 'error', message: 'Session secret 1 is shorter than 32 bytes.' }, + ]); + }); + + it('counts the bytes of a secret, not its characters', () => { + // 16 two-byte characters are 32 bytes, so this one is long enough. + const problems = checkSecuritySettings({ sessionSecrets: ['é'.repeat(16)] }, {}); + expect(problems).toEqual([]); + }); + + it('reports a secret that is also a public environment variable', () => { + const problems = checkSecuritySettings( + { sessionSecrets: [LONG_SECRET] }, + { NEXT_PUBLIC_SESSION_SECRET: LONG_SECRET }, + ); + expect(problems).toEqual([ + { + level: 'error', + message: + 'Session secret 1 is also the value of the NEXT_PUBLIC_SESSION_SECRET environment variable, ' + + 'which is sent to the browser. Use a different secret.', + }, + ]); + }); + + it('reports a secret for every public environment variable prefix', () => { + for (const prefix of ['NEXT_PUBLIC_', 'VITE_', 'PUBLIC_', 'NUXT_PUBLIC_', 'EXPO_PUBLIC_', 'REACT_APP_']) { + const problems = checkSecuritySettings( + { sessionSecrets: [LONG_SECRET] }, + { [`${prefix}KEY`]: LONG_SECRET }, + ); + expect(problems).toHaveLength(1); + expect(problems[0].message).toContain(`${prefix}KEY`); + } + }); + + it('ignores a private environment variable with the same value', () => { + const problems = checkSecuritySettings( + { sessionSecrets: [LONG_SECRET] }, + { SESSION_SECRET: LONG_SECRET }, + ); + expect(problems).toEqual([]); + }); + + it('never puts the secret in a message', () => { + const secret = 'thirty-two-bytes-exactly-long-ok'; + const problems = checkSecuritySettings( + { sessionSecrets: [secret] }, + { NEXT_PUBLIC_KEY: secret }, + ); + expect(problems.length).toBeGreaterThan(0); + for (const problem of problems) { + expect(problem.message).not.toContain(secret); + } + }); + + it('reports two session secrets that are the same', () => { + const problems = checkSecuritySettings({ sessionSecrets: [LONG_SECRET, LONG_SECRET] }, {}); + expect(problems).toEqual([ + { level: 'warning', message: 'Session secrets 1 and 2 are the same.' }, + ]); + }); + + it('reports an allowed origin that is not a URL', () => { + const problems = checkSecuritySettings({ allowedOrigins: ['acme.com'] }, {}); + expect(problems).toEqual([ + { level: 'error', message: 'Allowed origin 1 must start with http:// or https://.' }, + ]); + }); + + it('reports an allowed origin that is not a valid URL', () => { + const problems = checkSecuritySettings({ allowedOrigins: ['https://'] }, {}); + expect(problems).toEqual([ + { level: 'error', message: 'Allowed origin 1 is not a valid URL.' }, + ]); + }); + + it('reports an allowed origin with a path, a query or a trailing slash', () => { + const problems = checkSecuritySettings( + { allowedOrigins: ['https://acme.com/app', 'https://acme.com/?a=1', 'https://acme.com/'] }, + {}, + ); + expect(problems).toHaveLength(3); + for (const problem of problems) { + expect(problem.level).toBe('error'); + expect(problem.message).toContain('must not have a path, a query or a trailing slash'); + } + }); + + it('allows a plain http:// origin on localhost', () => { + const problems = checkSecuritySettings( + { allowedOrigins: ['http://localhost:5173', 'http://127.0.0.1:8080'] }, + {}, + ); + expect(problems).toEqual([]); + }); + + it('warns about an http:// origin that is not local', () => { + const problems = checkSecuritySettings({ allowedOrigins: ['http://acme.com'] }, {}); + expect(problems).toEqual([ + { + level: 'warning', + message: 'Allowed origin 1 uses http://. Use https://, unless it is a local development server.', + }, + ]); + }); + + it('warns when "*" is used together with other origins', () => { + const problems = checkSecuritySettings({ allowedOrigins: ['*', 'https://acme.com'] }, {}); + expect(problems).toEqual([ + { level: 'warning', message: 'Allowed origins include "*", so the other 1 entries do nothing.' }, + ]); + }); + + it('allows "*" on its own', () => { + expect(checkSecuritySettings({ allowedOrigins: ['*'] }, {})).toEqual([]); + }); + + it('reads the environment from process.env when none is given', () => { + const secret = 'a-secret-that-is-at-least-32-bytes-long'; + process.env.NEXT_PUBLIC_CHECK_SECRET = secret; + try { + const problems = checkSecuritySettings({ sessionSecrets: [secret] }); + expect(problems).toHaveLength(1); + expect(problems[0].message).toContain('NEXT_PUBLIC_CHECK_SECRET'); + } finally { + delete process.env.NEXT_PUBLIC_CHECK_SECRET; + } + }); +}); From be6ef6a9576649a12d764b3a0348134aeb16e9cd Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:28:59 +0530 Subject: [PATCH 79/80] feat(server): log security settings and mistakes at startup (#318) --- .changeset/security-startup-log.md | 5 ++ packages/server/src/handler.ts | 15 ++++- .../server/src/security/startup-checks.ts | 46 ++++++++++--- .../tests/security/startup-checks.test.ts | 67 ++++++++++++++++--- packages/server/tests/storage-config.test.ts | 4 +- website/src/content/docs/server/security.mdx | 9 +++ 6 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 .changeset/security-startup-log.md diff --git a/.changeset/security-startup-log.md b/.changeset/security-startup-log.md new file mode 100644 index 00000000..d5cf9f07 --- /dev/null +++ b/.changeset/security-startup-log.md @@ -0,0 +1,5 @@ +--- +"@chatcops/server": minor +--- + +The server logs its security settings once at startup (where the chat is allowed from, session tokens, Turnstile), plus an error or a warning for mistakes it can spot: an allowed origin without `http://` or `https://`, or with a path, query, fragment or trailing slash; an `http://` origin that isn't local; `"*"` next to other origins; a session secret that's also the value of a `NEXT_PUBLIC_`, `VITE_`, `PUBLIC_`, `NUXT_PUBLIC_`, `EXPO_PUBLIC_` or `REACT_APP_` variable; two secrets that are the same. diff --git a/packages/server/src/handler.ts b/packages/server/src/handler.ts index aa46b8bc..a5c007b6 100644 --- a/packages/server/src/handler.ts +++ b/packages/server/src/handler.ts @@ -31,7 +31,9 @@ import { } from '@chatcops/core'; import type { ChatCopsServerConfig, ValidatedChatRequest } from './config.js'; import { chatRequestSchema } from './config.js'; -import { createSessions } from './security/sessions.js'; +import { allowedOriginsOf } from './security/origin-policy.js'; +import { DEFAULT_SESSION_TTL_SEC, createSessions } from './security/sessions.js'; +import { logSecurityAtStartup } from './security/startup-checks.js'; import { VISITOR_CONTEXT_INSTRUCTION, buildVisitorContext, isLocaleTag, withVisitorContext } from './visitor-context.js'; import { WebhookDispatcher } from './webhook.js'; @@ -97,6 +99,16 @@ export function createChatHandler(config: ChatCopsServerConfig) { validateTools(toolDefs); const tools = toolRegistry(config.tools); const sessions = createSessions(config, async (id) => (await conversations.getMessages(id)).length === 0); + if (!loggedSecurity) { + loggedSecurity = true; + logSecurityAtStartup({ + allowedOrigins: allowedOriginsOf(config), + sessionSecrets: config.sessionSecrets, + sessionTtlSec: sessions ? (config.sessionTtlSec ?? DEFAULT_SESSION_TTL_SEC) : undefined, + requireSession: sessions?.required, + turnstile: Boolean(config.turnstile), + }); + } const requests: Record = { v1: 0, legacy: 0, 'ai-sdk': 0 }; @@ -519,6 +531,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; /** Once per process (per isolate on Workers), not once per handler. */ let warnedMemoryStorage = false; let loggedBudgets = false; +let loggedSecurity = false; let loggedLegacyFormat = false; const SCOPE_LABELS: Record = { conversation: 'per conversation', ip: 'per IP per day', global: 'per day in total' }; diff --git a/packages/server/src/security/startup-checks.ts b/packages/server/src/security/startup-checks.ts index 12023d0a..ef924fd1 100644 --- a/packages/server/src/security/startup-checks.ts +++ b/packages/server/src/security/startup-checks.ts @@ -94,12 +94,12 @@ export function checkSecuritySettings( } } - for (const [index, origin] of origins.entries()) { + for (const origin of origins) { if (origin === '*') continue; if (!origin.startsWith('http://') && !origin.startsWith('https://')) { problems.push({ level: 'error', - message: `Allowed origin ${index + 1} must start with http:// or https://.`, + message: `Allowed origin "${origin}" must start with http:// or https://.`, }); continue; } @@ -109,23 +109,23 @@ export function checkSecuritySettings( } catch { problems.push({ level: 'error', - message: `Allowed origin ${index + 1} is not a valid URL.`, + message: `Allowed origin "${origin}" is not a valid URL.`, }); continue; } - if (parsed.pathname !== '/' || parsed.search !== '' || origin.endsWith('/')) { + if (parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '' || /[/?#]$/.test(origin)) { problems.push({ level: 'error', message: - `Allowed origin ${index + 1} must not have a path, a query or a trailing slash, ` + - 'because browsers send the origin without one.', + `Allowed origin "${origin}" must not have a path, a query, a fragment or a trailing slash, ` + + `because browsers send the origin without one. Use "${parsed.origin}".`, }); continue; } if (origin.startsWith('http://') && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') { problems.push({ level: 'warning', - message: `Allowed origin ${index + 1} uses http://. Use https://, unless it is a local development server.`, + message: `Allowed origin "${origin}" uses http://. Use https://, unless it is a local development server.`, }); } } @@ -133,9 +133,39 @@ export function checkSecuritySettings( if (origins.includes('*') && origins.length > 1) { problems.push({ level: 'warning', - message: `Allowed origins include "*", so the other ${origins.length - 1} entries do nothing.`, + message: 'Allowed origins include "*", which allows every site, so the other entries do nothing.', }); } return problems; } + +/** What a server's security settings come to, for the startup log. */ +export interface SecuritySummary { + allowedOrigins: string[]; + /** Seconds, when session tokens are on. */ + sessionTtlSec?: number; + requireSession?: boolean; + turnstile: boolean; +} + +/** The one line the server logs at startup about its security settings. */ +export function describeSecurity(summary: SecuritySummary): string { + const origins = summary.allowedOrigins.includes('*') + ? 'any site (allowedOrigins is "*"; list your sites in production)' + : summary.allowedOrigins.join(', '); + const sessions = + summary.sessionTtlSec === undefined + ? 'session tokens off' + : `session tokens on (${summary.sessionTtlSec}s${summary.requireSession === false ? ', not required' : ''})`; + return `[chatcops] Security: chat allowed from ${origins}; ${sessions}; Turnstile ${summary.turnstile ? 'on' : 'off'}.`; +} + +/** Logs the problems `checkSecuritySettings` finds, errors first, then the summary line. */ +export function logSecurityAtStartup(settings: SecuritySettings & Omit): void { + const allowedOrigins = settings.allowedOrigins ?? ['*']; + const problems = checkSecuritySettings({ ...settings, allowedOrigins }); + for (const problem of problems.filter((p) => p.level === 'error')) console.error(`[chatcops] ERROR: ${problem.message}`); + for (const problem of problems.filter((p) => p.level === 'warning')) console.warn(`[chatcops] ${problem.message}`); + console.info(describeSecurity({ ...settings, allowedOrigins })); +} diff --git a/packages/server/tests/security/startup-checks.test.ts b/packages/server/tests/security/startup-checks.test.ts index 4e94a71a..02e1e6bd 100644 --- a/packages/server/tests/security/startup-checks.test.ts +++ b/packages/server/tests/security/startup-checks.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest'; -import { checkSecuritySettings } from '../../src/security/startup-checks.js'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { scriptedProvider } from '@chatcops/core/testing/provider'; +import { checkSecuritySettings, describeSecurity } from '../../src/security/startup-checks.js'; const LONG_SECRET = 'a-secret-that-is-at-least-32-bytes-long'; @@ -85,29 +86,34 @@ describe('checkSecuritySettings', () => { it('reports an allowed origin that is not a URL', () => { const problems = checkSecuritySettings({ allowedOrigins: ['acme.com'] }, {}); expect(problems).toEqual([ - { level: 'error', message: 'Allowed origin 1 must start with http:// or https://.' }, + { level: 'error', message: 'Allowed origin "acme.com" must start with http:// or https://.' }, ]); }); it('reports an allowed origin that is not a valid URL', () => { const problems = checkSecuritySettings({ allowedOrigins: ['https://'] }, {}); expect(problems).toEqual([ - { level: 'error', message: 'Allowed origin 1 is not a valid URL.' }, + { level: 'error', message: 'Allowed origin "https://" is not a valid URL.' }, ]); }); - it('reports an allowed origin with a path, a query or a trailing slash', () => { + it('reports an allowed origin with a path, a query, a fragment or a trailing slash, and says what to use', () => { const problems = checkSecuritySettings( - { allowedOrigins: ['https://acme.com/app', 'https://acme.com/?a=1', 'https://acme.com/'] }, + { allowedOrigins: ['https://acme.com/app', 'https://acme.com/?a=1', 'https://acme.com/', 'https://acme.com#top', 'https://acme.com?'] }, {}, ); - expect(problems).toHaveLength(3); + expect(problems).toHaveLength(5); for (const problem of problems) { expect(problem.level).toBe('error'); - expect(problem.message).toContain('must not have a path, a query or a trailing slash'); + expect(problem.message).toContain('must not have a path, a query, a fragment or a trailing slash'); + expect(problem.message).toContain('Use "https://acme.com".'); } }); + it('accepts subdomain wildcards and ports', () => { + expect(checkSecuritySettings({ allowedOrigins: ['https://*.acme.com', 'https://acme.com:8443'] }, {})).toEqual([]); + }); + it('allows a plain http:// origin on localhost', () => { const problems = checkSecuritySettings( { allowedOrigins: ['http://localhost:5173', 'http://127.0.0.1:8080'] }, @@ -121,7 +127,7 @@ describe('checkSecuritySettings', () => { expect(problems).toEqual([ { level: 'warning', - message: 'Allowed origin 1 uses http://. Use https://, unless it is a local development server.', + message: 'Allowed origin "http://acme.com" uses http://. Use https://, unless it is a local development server.', }, ]); }); @@ -129,7 +135,7 @@ describe('checkSecuritySettings', () => { it('warns when "*" is used together with other origins', () => { const problems = checkSecuritySettings({ allowedOrigins: ['*', 'https://acme.com'] }, {}); expect(problems).toEqual([ - { level: 'warning', message: 'Allowed origins include "*", so the other 1 entries do nothing.' }, + { level: 'warning', message: 'Allowed origins include "*", which allows every site, so the other entries do nothing.' }, ]); }); @@ -149,3 +155,44 @@ describe('checkSecuritySettings', () => { } }); }); + +describe('the startup log', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sums up the settings in one line', () => { + expect(describeSecurity({ allowedOrigins: ['*'], turnstile: false })).toBe( + '[chatcops] Security: chat allowed from any site (allowedOrigins is "*"; list your sites in production); session tokens off; Turnstile off.' + ); + expect(describeSecurity({ allowedOrigins: ['https://acme.com', 'https://*.acme.com'], sessionTtlSec: 900, requireSession: false, turnstile: true })).toBe( + '[chatcops] Security: chat allowed from https://acme.com, https://*.acme.com; session tokens on (900s, not required); Turnstile on.' + ); + }); + + it('logs errors, then warnings, then the summary, once per process', async () => { + vi.resetModules(); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined); + const { createChatHandler } = await import('../../src/handler.js'); + const config = { + provider: scriptedProvider([]), + systemPrompt: 'x', + budgets: false as const, + allowedOrigins: ['acme.com', 'http://staging.acme.com'], + sessionSecrets: ['a-session-secret-that-is-long-enough-1'], + }; + + createChatHandler(config).cleanup(); + createChatHandler(config).cleanup(); + + expect(error.mock.calls).toEqual([['[chatcops] ERROR: Allowed origin "acme.com" must start with http:// or https://.']]); + expect(warn.mock.calls).toEqual([ + ['[chatcops] Allowed origin "http://staging.acme.com" uses http://. Use https://, unless it is a local development server.'], + ]); + expect(info.mock.calls.filter(([line]) => String(line).startsWith('[chatcops] Security:'))).toEqual([ + ['[chatcops] Security: chat allowed from acme.com, http://staging.acme.com; session tokens on (900s); Turnstile off.'], + ]); + }); +}); diff --git a/packages/server/tests/storage-config.test.ts b/packages/server/tests/storage-config.test.ts index ca8a7426..b370f81f 100644 --- a/packages/server/tests/storage-config.test.ts +++ b/packages/server/tests/storage-config.test.ts @@ -174,7 +174,7 @@ describe('default budgets', () => { const { createChatHandler: fresh } = await import('../src/handler.js'); fresh({ provider: scriptedProvider([]), systemPrompt: 'x' }).cleanup(); fresh({ provider: scriptedProvider([]), systemPrompt: 'x' }).cleanup(); - expect(info.mock.calls).toEqual([ + expect(info.mock.calls.filter(([line]) => String(line).includes('Budgets'))).toEqual([ [ '[chatcops] Budgets: 200 replies per conversation (default), 2M tokens per IP per day (default). No daily total is set; `budgets.globalPerDay` caps your whole bill. Set `budgets` to change them, or `budgets: false` to turn them off.', ], @@ -186,7 +186,7 @@ describe('default budgets', () => { const info = vi.spyOn(console, 'info').mockImplementation(() => {}); const { createChatHandler: fresh } = await import('../src/handler.js'); fresh({ provider: scriptedProvider([]), systemPrompt: 'x', budgets: false }).cleanup(); - expect(info).not.toHaveBeenCalled(); + expect(info.mock.calls.filter(([line]) => String(line).includes('Budgets'))).toEqual([]); }); it('describes budgets you set', () => { diff --git a/website/src/content/docs/server/security.mdx b/website/src/content/docs/server/security.mdx index ca0aa862..e2419fb9 100644 --- a/website/src/content/docs/server/security.mdx +++ b/website/src/content/docs/server/security.mdx @@ -128,6 +128,15 @@ With `turnstile` on the server, give the widget the site key. Nothing else chang ## Before you go live +The server logs one line about these settings when it starts, and an error or warning for the mistakes it can spot: an origin without `https://` or with a path, an `http://` origin that isn't local, a session secret that's also in a `NEXT_PUBLIC_` (or `VITE_`, `PUBLIC_` and so on) variable. For example: + +``` +[chatcops] ERROR: Allowed origin "acme.com" must start with http:// or https://. +[chatcops] Security: chat allowed from acme.com; session tokens on (900s); Turnstile off. +``` + +Then go through the list: + - `allowedOrigins` lists your sites, and nothing else. `['*']` is the default. - `storage` is a store package, so limits and budgets hold across instances. - `budgets.globalPerDay` is set to a number you're fine paying on a bad day. From 5b31899e2aeb4ebedfa2e825ccd114a47714d6a1 Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:56:58 +0530 Subject: [PATCH 80/80] feat(widget): say why a reply failed: rate limit, budget, unavailable, cut off (#319) --- .changeset/widget-limit-messages.md | 5 +++ .size-limit.json | 4 +-- CONTRIBUTING.md | 2 +- packages/widget/src/api/client.ts | 29 ++++++++++++----- packages/widget/src/api/protocol.ts | 2 +- packages/widget/src/api/types.ts | 2 ++ packages/widget/src/dom/messages.ts | 2 +- packages/widget/src/i18n.ts | 11 +++++++ packages/widget/src/storage.ts | 2 +- packages/widget/src/widget.ts | 38 +++++++++++------------ packages/widget/tests/api/client.test.ts | 28 +++++++++++++++-- packages/widget/tests/api/session.test.ts | 4 +-- packages/widget/tests/protocol.test-d.ts | 2 ++ packages/widget/tests/widget.test.ts | 32 +++++++++++++++++++ website/src/content/docs/widget/i18n.mdx | 20 +++++++++++- 15 files changed, 144 insertions(+), 39 deletions(-) create mode 100644 .changeset/widget-limit-messages.md diff --git a/.changeset/widget-limit-messages.md b/.changeset/widget-limit-messages.md new file mode 100644 index 00000000..db7c7a9c --- /dev/null +++ b/.changeset/widget-limit-messages.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": minor +--- + +Clearer messages when a reply can't happen. The widget now reads the server's reason and says it: too many messages, with the wait in seconds when the server sends one; the conversation or the day's budget used up; the assistant unavailable (the provider couldn't be set up); or the reply cut off. Each one is a new `strings` key (`errorRateLimitWait`, `errorBudget`, `errorBudgetDaily`, `errorUnavailable`, `errorCutOff`). The script tag bundle's size budget goes from 14 kB to 14.5 kB (18 kB for ESM) to fit them. diff --git a/.size-limit.json b/.size-limit.json index f218d7e0..e9363892 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,13 +2,13 @@ { "name": "widget, script tag (IIFE)", "path": "packages/widget/dist/chatcops.min.js", - "limit": "14 KB", + "limit": "14.5 KB", "gzip": true }, { "name": "widget, ESM", "path": "packages/widget/dist/chatcops.esm.js", - "limit": "17.5 KB", + "limit": "18 KB", "gzip": true } ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3937c2bc..5373d5a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -203,7 +203,7 @@ ANTHROPIC_API_KEY=sk-... npx tsx src/examples/express-server.ts `npx` downloads `tsx` the first time. -The widget has a size budget: 14 kB gzipped for the script tag bundle (`dist/chatcops.min.js`), checked in CI by `pnpm size` after a build (limits in `.size-limit.json`). +The widget has a size budget: 14.5 kB gzipped for the script tag bundle (`dist/chatcops.min.js`) and 18 kB for the ESM build, checked in CI by `pnpm size` after a build (limits in `.size-limit.json`). ### Browser tests diff --git a/packages/widget/src/api/client.ts b/packages/widget/src/api/client.ts index d77e4ce0..e5483aed 100644 --- a/packages/widget/src/api/client.ts +++ b/packages/widget/src/api/client.ts @@ -132,12 +132,12 @@ export class ChatClient { } if (!response.ok) { + // Protocol v1 servers send `{ error: { code, retryAfterMs } }`. + const { error } = await response.json().catch(() => ({})); await cleanup(); - if (response.status === 429) { - yield { error: 'rate_limit' }; - return; - } - yield { error: `HTTP ${response.status}` }; + yield error?.code + ? errorChunk(error.code, error.retryAfterMs) + : { error: response.status === 429 ? 'rate_limit' : `HTTP ${response.status}` }; return; } @@ -173,7 +173,7 @@ export class ChatClient { const data = trimmed.slice(6); if (data === '[DONE]') { await cleanup(); - yield v1 && !finished ? { error: 'network' } : { done: true }; + yield v1 && !finished ? { error: 'cut_off' } : { done: true }; return; } @@ -198,7 +198,7 @@ export class ChatClient { // A [DONE] with no newline after it still counts. Anything else means the // connection was cut mid-reply (a proxy timeout, a server crash), so the // visitor should see an error and a Retry button rather than half an answer. - yield buffer.trim() === 'data: [DONE]' ? { done: true } : { error: 'network' }; + yield buffer.trim() === 'data: [DONE]' ? { done: true } : { error: v1 ? 'cut_off' : 'network' }; } catch (err) { await cleanup(); if (err instanceof DOMException && err.name === 'AbortError') { @@ -212,6 +212,19 @@ export class ChatClient { } } +/** The widget's error type for a protocol v1 error code. */ +const ERROR_TYPES: Record = { + rate_limited: 'rate_limit', + timeout: 'timeout', + budget_exceeded: 'budget', + budget_daily: 'budget_daily', + provider_unavailable: 'unavailable', +}; + +function errorChunk(code: string, retryAfterMs?: number): WidgetChatChunk { + return { error: ERROR_TYPES[code] ?? 'provider_error', retryAfterMs }; +} + /** * A protocol v1 part as the chunk the widget already handles, or null for * parts it doesn't use (and part types added later). @@ -225,7 +238,7 @@ function fromPart(part: WirePart): WidgetChatChunk | null { case 'lead-captured': return { leadCaptured: true, leadData: part.lead }; case 'error': - return { error: part.code === 'rate_limited' ? 'rate_limit' : part.code === 'timeout' ? 'timeout' : 'provider_error' }; + return errorChunk(part.code === 'budget_exceeded' && part.scope !== 'conversation' ? 'budget_daily' : part.code, part.retryAfterMs); case 'finish': // An error part already went out, or nobody is listening. return part.reason === 'error' || part.reason === 'aborted' ? null : { done: true }; diff --git a/packages/widget/src/api/protocol.ts b/packages/widget/src/api/protocol.ts index 26c99873..84246894 100644 --- a/packages/widget/src/api/protocol.ts +++ b/packages/widget/src/api/protocol.ts @@ -7,7 +7,7 @@ export type WirePart = | { type: 'start'; protocol: 1; conversationId: string; messageId: string } | { type: 'text-delta'; id: string; delta: string } | { type: 'lead-captured'; lead: Record } - | { type: 'error'; code: string; message?: string; retryable: boolean; retryAfterMs?: number } + | { type: 'error'; code: string; message?: string; retryable: boolean; retryAfterMs?: number; scope?: string } | { type: 'finish'; reason: string } /** Parts the widget doesn't use yet. */ | { type: 'text-start' | 'text-end' | 'source' | 'tool-call' | 'tool-result' | 'handoff' | 'agent-message' | 'suggestions' }; diff --git a/packages/widget/src/api/types.ts b/packages/widget/src/api/types.ts index ae7ff5aa..bdb25187 100644 --- a/packages/widget/src/api/types.ts +++ b/packages/widget/src/api/types.ts @@ -22,6 +22,8 @@ export interface WidgetChatChunk { content?: string; done?: boolean; error?: string; + /** How long to wait before trying again, with a `rate_limit` error. */ + retryAfterMs?: number; leadCaptured?: boolean; leadData?: Record; suggestedActions?: string[]; diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts index f627d769..ae6992ca 100644 --- a/packages/widget/src/dom/messages.ts +++ b/packages/widget/src/dom/messages.ts @@ -4,7 +4,7 @@ const RETRY_ICON = '; updatedAt: number; diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index b9652f89..9cc33869 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -11,6 +11,17 @@ import { PreChatForm } from './dom/prechat-form.js'; import type { WidgetChatRequest } from './api/types.js'; import type { MessageData, MessageErrorType } from './dom/messages.js'; +/** The locale string for each kind of failed reply. */ +const ERROR_MESSAGES: Partial> = { + network: 'errorNetwork', + timeout: 'errorNetwork', + provider_error: 'errorGeneric', + budget: 'errorBudget', + budget_daily: 'errorBudgetDaily', + unavailable: 'errorUnavailable', + cut_off: 'errorCutOff', +}; + export interface PreChatField { name: string; type: 'text' | 'email' | 'select' | 'textarea'; @@ -486,7 +497,7 @@ export class Widget { if (this.destroyed) break; if (chunk.error) { const errorType = this.resolveErrorType(chunk.error); - this.markFailed(assistantMsg, this.getErrorMessage(errorType)); + this.markFailed(assistantMsg, this.getErrorMessage(errorType, chunk.retryAfterMs)); assistantMsg.errorType = errorType; hasError = true; @@ -658,15 +669,7 @@ export class Widget { } private resolveErrorType(error: string): MessageErrorType { - switch (error) { - case 'rate_limit': - case 'network': - case 'provider_error': - case 'timeout': - return error; - default: - return 'provider_error'; - } + return error in ERROR_MESSAGES || error === 'rate_limit' ? (error as MessageErrorType) : 'provider_error'; } /** @@ -682,16 +685,13 @@ export class Widget { } } - private getErrorMessage(errorType: MessageErrorType): string { - switch (errorType) { - case 'rate_limit': - return this.locale.errorRateLimit; - case 'network': - case 'timeout': - return this.locale.errorNetwork; - default: - return this.locale.errorGeneric; + private getErrorMessage(errorType: MessageErrorType, retryAfterMs?: number): string { + if (errorType === 'rate_limit') { + return retryAfterMs + ? this.locale.errorRateLimitWait.replace('{seconds}', String(Math.ceil(retryAfterMs / 1000))) + : this.locale.errorRateLimit; } + return this.locale[ERROR_MESSAGES[errorType] ?? 'errorGeneric']; } private saveHistory(): void { diff --git a/packages/widget/tests/api/client.test.ts b/packages/widget/tests/api/client.test.ts index 0587c937..4f0028e4 100644 --- a/packages/widget/tests/api/client.test.ts +++ b/packages/widget/tests/api/client.test.ts @@ -358,18 +358,40 @@ describe('ChatClient with protocol v1', () => { part({ type: 'finish', reason: 'error' }), '[DONE]', ]); - expect((await collect()).slice(1, 2)).toEqual([{ error: 'rate_limit' }]); + expect((await collect()).slice(1, 2)).toEqual([{ error: 'rate_limit', retryAfterMs: 5000 }]); respond([part({ type: 'error', code: 'timeout', retryable: true }), part({ type: 'finish', reason: 'error' }), '[DONE]']); expect((await collect())[0]).toEqual({ error: 'timeout' }); - respond([part({ type: 'error', code: 'budget_exceeded', retryable: false }), part({ type: 'finish', reason: 'error' }), '[DONE]']); + respond([part({ type: 'error', code: 'budget_exceeded', retryable: false, scope: 'conversation' }), part({ type: 'finish', reason: 'error' }), '[DONE]']); + expect((await collect())[0]).toEqual({ error: 'budget' }); + + respond([part({ type: 'error', code: 'budget_exceeded', retryable: false, scope: 'ip' }), part({ type: 'finish', reason: 'error' }), '[DONE]']); + expect((await collect())[0]).toEqual({ error: 'budget_daily' }); + + respond([part({ type: 'error', code: 'provider_error', retryable: true }), part({ type: 'finish', reason: 'error' }), '[DONE]']); expect((await collect())[0]).toEqual({ error: 'provider_error' }); }); + it('reads the error body of a refusal before the stream', async () => { + const refuse = (status: number, body: string) => vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(body, { status })); + + refuse(429, JSON.stringify({ error: { code: 'rate_limited', retryAfterMs: 42_000 } })); + expect(await collect()).toEqual([{ error: 'rate_limit', retryAfterMs: 42_000 }]); + + refuse(503, JSON.stringify({ error: { code: 'provider_unavailable' } })); + expect(await collect()).toEqual([{ error: 'unavailable' }]); + + // A 0.5 server, or a proxy page: no JSON body. + refuse(429, 'Too Many Requests'); + expect(await collect()).toEqual([{ error: 'rate_limit' }]); + refuse(502, 'Bad gateway'); + expect(await collect()).toEqual([{ error: 'HTTP 502' }]); + }); + it('reports a v1 stream that ends without finish as cut off', async () => { respond([part({ type: 'start', protocol: 1, conversationId: 'c1', messageId: 'm' }), part({ type: 'text-delta', id: 't', delta: 'Half' }), '[DONE]']); - expect((await collect()).at(-1)).toEqual({ error: 'network' }); + expect((await collect()).at(-1)).toEqual({ error: 'cut_off' }); }); it('still reads the 0.5 format from older servers', async () => { diff --git a/packages/widget/tests/api/session.test.ts b/packages/widget/tests/api/session.test.ts index 53e1fe84..454c264f 100644 --- a/packages/widget/tests/api/session.test.ts +++ b/packages/widget/tests/api/session.test.ts @@ -100,7 +100,7 @@ describe('ChatClient with session tokens', () => { vi.spyOn(globalThis, 'fetch') .mockResolvedValueOnce(unauthorized()) .mockResolvedValueOnce(new Response('{}', { status: 404 })); - expect(await collect(new ChatClient({ apiUrl: API }))).toEqual([{ error: 'HTTP 401' }]); + expect(await collect(new ChatClient({ apiUrl: API }))).toEqual([{ error: 'provider_error' }]); }); it('keeps working when storage is unavailable', async () => { @@ -152,7 +152,7 @@ describe('ChatClient with session tokens', () => { .mockResolvedValueOnce(v1Reply()); const client = new ChatClient({ apiUrl: API, turnstileSiteKey: 'site-key' }); - expect(await collect(client)).toEqual([{ error: 'HTTP 401' }]); + expect(await collect(client)).toEqual([{ error: 'provider_error' }]); expect(turnstileToken).not.toHaveBeenCalled(); await collect(client); diff --git a/packages/widget/tests/protocol.test-d.ts b/packages/widget/tests/protocol.test-d.ts index 08217abf..42e69488 100644 --- a/packages/widget/tests/protocol.test-d.ts +++ b/packages/widget/tests/protocol.test-d.ts @@ -13,5 +13,7 @@ describe('the protocol types the widget copies from core', () => { expectTypeOf['messageId']>().toEqualTypeOf(); expectTypeOf['delta']>().toEqualTypeOf(); expectTypeOf['lead']>().toEqualTypeOf>(); + expectTypeOf['scope']>().toExtend(); + expectTypeOf['retryAfterMs']>().toEqualTypeOf(); }); }); diff --git a/packages/widget/tests/widget.test.ts b/packages/widget/tests/widget.test.ts index 0bce5b4b..276bde8d 100644 --- a/packages/widget/tests/widget.test.ts +++ b/packages/widget/tests/widget.test.ts @@ -364,6 +364,38 @@ describe('Widget', () => { expect(lastBubble().dataset.aiGenerated).toBe('true'); }); + it.each([ + [{ error: 'rate_limit', retryAfterMs: 41_200 }, 'rate_limit', 'Too many messages. Please try again in 42 seconds.'], + [{ error: 'rate_limit' }, 'rate_limit', 'Too many messages. Please wait a moment.'], + [{ error: 'budget' }, 'budget', 'This chat has reached its limit.'], + [{ error: 'budget_daily' }, 'budget_daily', 'The assistant has reached its limit for today. Please try again later.'], + [{ error: 'unavailable' }, 'unavailable', 'The assistant is unavailable right now. Please try again later.'], + [{ error: 'cut_off' }, 'cut_off', 'The reply was cut off.'], + [{ error: 'HTTP 502' }, 'provider_error', 'Something went wrong. Please try again.'], + ])('shows %o as %s with its own message', async (chunk, errorType, text) => { + const widget = new Widget({ apiUrl: 'https://api.test/chat' }); + widget.init(); + getInternals(widget).client.sendMessage = vi.fn().mockImplementationOnce(async function* () { + yield chunk; + }); + + await getInternals(widget).handleSend('Hi'); + + expect(getInternals(widget).messages.at(-1)).toMatchObject({ status: 'error', errorType, content: text }); + widget.destroy(); + }); + + it('takes the limit messages from strings', async () => { + const widget = new Widget({ apiUrl: 'https://api.test/chat', strings: { errorRateLimitWait: 'Warte {seconds} Sekunden.' } }); + widget.init(); + getInternals(widget).client.sendMessage = vi.fn().mockImplementationOnce(async function* () { + yield { error: 'rate_limit', retryAfterMs: 3000 }; + }); + await getInternals(widget).handleSend('Hi'); + expect(getInternals(widget).messages.at(-1)?.content).toBe('Warte 3 Sekunden.'); + widget.destroy(); + }); + it('keeps streamed text when an error arrives partway through and shows the error under it', async () => { const widget = new Widget({ apiUrl: 'https://api.test/chat' }); widget.init(); diff --git a/website/src/content/docs/widget/i18n.mdx b/website/src/content/docs/widget/i18n.mdx index 730d6fa9..211b2f26 100644 --- a/website/src/content/docs/widget/i18n.mdx +++ b/website/src/content/docs/widget/i18n.mdx @@ -66,6 +66,11 @@ interface LocaleStrings { errorGeneric: string; errorNetwork: string; errorRateLimit: string; + errorRateLimitWait: string; // {seconds} is how long to wait + errorBudget: string; + errorBudgetDaily: string; + errorUnavailable: string; + errorCutOff: string; typingIndicator: string; poweredBy: string; newConversation: string; @@ -88,4 +93,17 @@ The server doesn't translate anything itself. When a request has a `locale`, it Reply in the visitor's language. Their browser locale is es. ``` -Error messages the visitor sees come from the widget's `strings` (`errorGeneric`, `errorNetwork`, `errorRateLimit`). The server config's `i18n` option is deprecated: the server ignores it and logs a warning when it's set, and it will be removed in a later release. +Error messages the visitor sees come from the widget's `strings`: + +| Key | Shown when | English | +|---|---|---| +| `errorRateLimit` | Too many messages, and the server didn't say for how long | Too many messages. Please wait a moment. | +| `errorRateLimitWait` | Too many messages, with the server's wait (`{seconds}`) | Too many messages. Please try again in \{seconds\} seconds. | +| `errorBudget` | The conversation used up its budget | This chat has reached its limit. | +| `errorBudgetDaily` | The visitor's or the site's daily budget is used up | The assistant has reached its limit for today. Please try again later. | +| `errorUnavailable` | The server couldn't set up the AI provider (a missing key, say) | The assistant is unavailable right now. Please try again later. | +| `errorCutOff` | The reply stopped before the server said it was done | The reply was cut off. | +| `errorNetwork` | The request failed or timed out | Connection error. Please check your network. | +| `errorGeneric` | Anything else | Something went wrong. Please try again. | + +The limit and cut-off messages need a 0.6 server, which sends the reason. With an older server, the widget falls back to `errorRateLimit`, `errorNetwork` and `errorGeneric`. The server config's `i18n` option is deprecated: the server ignores it and logs a warning when it's set, and it will be removed in a later release.