diff --git a/README.md b/README.md index de4c12b..417b56d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,161 @@ -# webagent -Embeddable GenAI-powered web chat widget with client-side agent loop, tool calling, WebMCP support, and Cloudflare Workers backend +# @vibetechnologies/webagent + +Embeddable GenAI-powered web chat widget with client-side agent loop, tool calling, WebMCP support, and Cloudflare Workers backend. + +[](https://github.com/VibeTechnologies/webagent/actions/workflows/ci.yml) +[](https://www.npmjs.com/package/@vibetechnologies/webagent) + +## Features + +- ๐ค Client-side AI agent loop (Vercel AI SDK) +- ๐ง 5 built-in tools: web_fetch, skill, todo, send_email, escalate_to_human +- ๐ TF-IDF knowledge base search +- ๐พ Session persistence (IndexedDB / localStorage) +- ๐ WebMCP support (Chrome 146+) โ provider + consumer +- ๐จ Shadow DOM isolation โ works on any website +- โ๏ธ Cloudflare Workers backend (D1, KV, R2, Queues) +- ๐ Hybrid API keys: BYOK + managed + +## Quick Start + +### CDN (Recommended) + +```html + + +``` + +### npm + +```bash +npm install @vibetechnologies/webagent +``` + +```typescript +import { WebAgent } from '@vibetechnologies/webagent'; + +const agent = WebAgent.init({ + apiBase: 'https://your-worker.workers.dev', + apiKey: 'wa_your_managed_key', + welcomeMessage: 'Hi! How can I help?', + theme: 'dark', + position: 'bottom-right', + persistence: 'indexeddb', + sessionTtlDays: 7, +}); + +// Later: agent.destroy(); +``` + +## Configuration + +| Option | Type | Default | Description | +|---|---|---|---| +| `apiBase` | `string` | *required* | Backend worker URL | +| `apiKey` | `string` | `undefined` | API key (BYOK `sk-...` or managed `wa_...`) | +| `systemPrompt` | `string` | `'You are a helpful support agent.'` | System prompt | +| `skills` | `Skill[]` | `[]` | API skills for tool calling | +| `knowledgeBaseUrl` | `string` | `undefined` | URL to knowledge base JSON | +| `theme` | `'light' \| 'dark' \| 'auto' \| ThemeConfig` | `'light'` | Theme | +| `position` | `'bottom-right' \| 'bottom-left'` | `'bottom-right'` | FAB position | +| `welcomeMessage` | `string` | `undefined` | Initial greeting | +| `persistence` | `'indexeddb' \| 'localStorage' \| 'none'` | `'indexeddb'` | Session storage | +| `maxSteps` | `number` | `10` | Max tool-calling steps | +| `model` | `string` | `'gpt-4o-mini'` | LLM model name | +| `supportEmail` | `string` | `'support@example.com'` | Escalation recipient | +| `sessionTtlDays` | `number` | `7` | Session TTL in days | +| `onEscalation` | `(ticket) => void` | `undefined` | Escalation callback | +| `onMessage` | `(message) => void` | `undefined` | Message callback | + +## Skills + +See the [Skill Authoring Guide](docs/skills.md) for full documentation on creating and registering API skills. + +## Architecture + +``` +โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +โ Host Website โ +โ โโโโโโโโโโโโโโโโโโโโโโโโโโ โ +โ โ Shadow DOM โ โ +โ โ โโโโโโโโโโโโโโโโโโโโ โ โ +โ โ โ Chat Widget UI โ โ โ +โ โ โ (Preact) โ โ โ +โ โ โโโโโโโโโโฌโโโโโโโโโโ โ โ +โ โ โ โ โ +โ โ โโโโโโโโโโผโโโโโโโโโโ โ โ +โ โ โ Agent Loop โ โ โ +โ โ โ (Vercel AI SDK) โ โ โ +โ โ โ โโโโโโโโโโโโโโโ โ โ โ +โ โ โ โ Tools โ โ โ โ +โ โ โ โ โข web_fetch โ โ โ โ +โ โ โ โ โข skill โ โ โ โ +โ โ โ โ โข todo โ โ โ โ +โ โ โ โ โข send_email โ โ โ โ +โ โ โ โ โข escalate โ โ โ โ +โ โ โ โโโโโโโโโโโโโโโ โ โ โ +โ โ โโโโโโโโโโฌโโโโโโโโโโ โ โ +โ โโโโโโโโโโโโโผโโโโโโโโโโโโโ โ +โโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโ + โ HTTPS +โโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโ +โ Cloudflare Workers โ +โ โโโโโโโโโโโ โโโโโโโโโโโโโ โ +โ โ LLM โ โ Fetch โ โ +โ โ Proxy โ โ Proxy โ โ +โ โโโโโโโโโโโ โโโโโโโโโโโโโ โ +โ โโโโโโโโโโโ โโโโโโโโโโโโโ โ +โ โ Email โ โ Escalate โ โ +โ โ Queue โ โ Tickets โ โ +โ โโโโโโโโโโโ โโโโโโโโโโโโโ โ +โ โโโโโโโโโโโ โโโโโโโโโโโโโ โ +โ โ KB (R2) โ โ Admin โ โ +โ โโโโโโโโโโโ โโโโโโโโโโโโโ โ +โ D1 ยท KV ยท R2 ยท Queues โ +โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +``` + +## WebMCP Support + +The widget supports the [WebMCP](https://developer.chrome.com/blog/webmcp-epp) standard (Chrome 146+): + +### As Provider +Skills registered with the widget are automatically exposed via `navigator.modelContext.registerTool()`, making them available to browser-level AI agents. + +### As Consumer +The widget can discover and use tools registered by the host page or other extensions via the WebMCP API. + +### Declarative Forms +```html +
+``` + +## Packages + +| Package | Description | +|---|---| +| `@vibetechnologies/webagent` | Client-side chat widget | +| `@vibetechnologies/webagent-backend` | Cloudflare Workers backend | + +## Development + +```bash +pnpm install +pnpm typecheck # Type-check all packages +pnpm build # Build all packages +pnpm test # Run tests +pnpm dev # Dev mode (all packages) +``` + +## License + +MIT diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..0388c33 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,183 @@ +# Deployment Guide + +## Prerequisites + +- [Node.js 22+](https://nodejs.org/) +- [pnpm 9+](https://pnpm.io/) +- [Cloudflare account](https://dash.cloudflare.com/) +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) + +## Backend Deployment + +### 1. Create Cloudflare Resources + +```bash +# Login to Cloudflare +npx wrangler login + +# Create D1 database +npx wrangler d1 create webagent-db + +# Create KV namespace +npx wrangler kv namespace create KV + +# Create R2 bucket +npx wrangler r2 bucket create webagent-kb + +# Create Queue +npx wrangler queues create webagent-emails +``` + +### 2. Update wrangler.toml + +Update `packages/backend/wrangler.toml` with the IDs from step 1: + +```toml +[[d1_databases]] +binding = "DB" +database_name = "webagent-db" +database_id = "YOUR_D1_DATABASE_ID" + +[[kv_namespaces]] +binding = "KV" +id = "YOUR_KV_NAMESPACE_ID" +``` + +### 3. Set Secrets + +```bash +cd packages/backend + +# Admin secret for API key management +npx wrangler secret put ADMIN_SECRET + +# Resend API key for email delivery +npx wrangler secret put RESEND_API_KEY +``` + +### 4. Run Migrations + +```bash +npx wrangler d1 migrations apply webagent-db +``` + +### 5. Deploy + +```bash +pnpm --filter @vibetechnologies/webagent-backend deploy +``` + +## Widget Publishing + +### npm + +```bash +cd packages/widget +pnpm build +npm publish --access public +``` + +### CDN + +After publishing to npm, the widget is available via: +- unpkg: `https://unpkg.com/@vibetechnologies/webagent/dist/webagent.min.js` +- jsDelivr: `https://cdn.jsdelivr.net/npm/@vibetechnologies/webagent/dist/webagent.min.js` + +## Customer API Key Setup + +### Create a managed key + +```bash +curl -X POST https://your-worker.workers.dev/api/admin/keys \ + -H "Content-Type: application/json" \ + -H "X-Admin-Secret: YOUR_ADMIN_SECRET" \ + -d '{ + "customerId": "customer-123", + "provider": "openai", + "providerApiKey": "sk-..." + }' +``` + +Response: `{ "success": true, "customerKey": "wa_abc123...", "customerId": "customer-123" }` + +### BYOK (Bring Your Own Key) + +Customers can pass their own OpenAI/Anthropic key directly: + +```html + +``` + +## Knowledge Base Setup + +### Upload knowledge base chunks + +```bash +curl -X PUT https://your-worker.workers.dev/api/kb/my-docs \ + -H "Content-Type: application/json" \ + -H "X-Admin-Secret: YOUR_ADMIN_SECRET" \ + -d '[ + { + "id": "getting-started", + "title": "Getting Started", + "content": "Welcome to our product...", + "keywords": ["setup", "install", "start"] + } + ]' +``` + +### Configure widget to use KB + +```html + +``` + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `ADMIN_SECRET` | Yes | Secret for admin API endpoints | +| `RESEND_API_KEY` | Yes | [Resend](https://resend.com) API key for email | + +## Cloudflare Free Tier Limits + +| Resource | Limit | +|---|---| +| Workers requests | 100,000/day | +| D1 reads | 5,000,000/day | +| D1 writes | 100,000/day | +| D1 storage | 5 GB | +| KV reads | 100,000/day | +| KV writes | 1,000/day | +| R2 storage | 10 GB | +| R2 reads | 10,000,000/month | +| Queues messages | 1,000,000/month | + +## CI/CD + +The repository includes GitHub Actions workflows: + +- **CI** (`.github/workflows/ci.yml`): Runs on PRs โ typecheck, lint, test, build +- **Publish** (`.github/workflows/publish.yml`): Publishes to npm on GitHub Release +- **Deploy** (`.github/workflows/deploy.yml`): Deploys backend on push to main + +### Setting up CI/CD secrets + +In your GitHub repo settings โ Secrets: + +| Secret | Purpose | +|---|---| +| `NPM_TOKEN` | npm publish token | +| `CLOUDFLARE_API_TOKEN` | Wrangler deploy token | +| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 0000000..2fb8395 --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,106 @@ +# Skill Authoring Guide + +Skills are pre-configured API integrations that the WebAgent can execute on behalf of users. They enable the agent to perform actions like searching products, placing orders, or looking up account information. + +## Skill Definition + +```typescript +interface Skill { + name: string; // Unique identifier (e.g., 'search-products') + title: string; // Human-readable name + description: string; // LLM-facing description + parameters: object; // JSON Schema for tool parameters + endpoint: string; // API endpoint URL (supports {param} templates) + method: string; // HTTP method + headers?: object; // Custom headers + bodyTemplate?: object; // Request body template with {{param}} placeholders + readOnly?: boolean; // Hint for WebMCP annotations +} +``` + +## Example: Product Search Skill + +```typescript +const searchProducts = { + name: 'search-products', + title: 'Search Products', + description: 'Search the product catalog by keyword, category, or price range', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search keywords' }, + category: { type: 'string', description: 'Product category' }, + maxPrice: { type: 'number', description: 'Maximum price' }, + }, + required: ['query'], + }, + endpoint: 'https://api.store.com/products/search', + method: 'POST', + headers: { + 'X-Store-ID': 'my-store', + }, + bodyTemplate: { + q: '{{query}}', + category: '{{category}}', + price_max: '{{maxPrice}}', + }, +}; +``` + +## Example: Order Lookup Skill + +```typescript +const lookupOrder = { + name: 'lookup-order', + title: 'Look Up Order', + description: 'Look up an order by order ID or email address', + parameters: { + type: 'object', + properties: { + orderId: { type: 'string', description: 'Order ID' }, + email: { type: 'string', description: 'Customer email' }, + }, + }, + endpoint: 'https://api.store.com/orders/{orderId}', + method: 'GET', + readOnly: true, +}; +``` + +## URL Template Parameters + +Parameters in `{braces}` in the endpoint URL are substituted from the tool call args: +- `https://api.example.com/users/{userId}` โ `https://api.example.com/users/123` + +## Body Template Parameters + +Parameters in `{{double braces}}` in the bodyTemplate are substituted: +- `{ "q": "{{query}}" }` โ `{ "q": "wireless headphones" }` + +## Registering Skills + +```typescript +WebAgent.init({ + apiBase: 'https://your-worker.workers.dev', + apiKey: 'wa_xxx', + skills: [searchProducts, lookupOrder], +}); +``` + +## WebMCP Integration + +When skills are registered, they are automatically exposed via the WebMCP API (`navigator.modelContext.registerTool()`). This means: + +1. Browser-level AI agents can discover your skills +2. The `readOnly` flag maps to WebMCP's `annotations.readOnlyHint` +3. Skills follow the OpenAI function-calling parameter schema + +## Best Practices + +1. **Write clear descriptions** โ The LLM uses these to decide when to call the skill +2. **Use JSON Schema properly** โ Include `description` on each parameter +3. **Mark read-only skills** โ Set `readOnly: true` for GET/lookup operations +4. **Use URL templates for path params** โ `{param}` in endpoint URL +5. **Use body templates for POST data** โ `{{param}}` in bodyTemplate +6. **Handle errors gracefully** โ The agent will receive error responses and can retry or inform the user +7. **Keep skills focused** โ One skill = one API action diff --git a/packages/backend/src/__tests__/admin.test.ts b/packages/backend/src/__tests__/admin.test.ts new file mode 100644 index 0000000..e4828c1 --- /dev/null +++ b/packages/backend/src/__tests__/admin.test.ts @@ -0,0 +1,73 @@ +import { Hono } from 'hono'; +import { adminRoute } from '../routes/admin'; +import { createMockBindings } from './test-helpers'; + +describe('admin route', () => { + it('returns 401 without admin secret', async () => { + const app = new Hono(); + app.route('/api/admin', adminRoute); + + const { bindings } = createMockBindings(); + const res = await app.request('/api/admin/keys', { method: 'GET' }, bindings as any); + + expect(res.status).toBe(401); + }); + + it('returns 401 for wrong admin secret', async () => { + const app = new Hono(); + app.route('/api/admin', adminRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/admin/keys', + { method: 'GET', headers: { 'X-Admin-Secret': 'wrong-secret' } }, + bindings as any + ); + + expect(res.status).toBe(401); + }); + + it('creates a managed key and persists customer for list endpoint', async () => { + const app = new Hono(); + app.route('/api/admin', adminRoute); + + const { bindings, stores } = createMockBindings(); + + const createRes = await app.request( + '/api/admin/keys', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-Secret': 'admin-secret', + }, + body: JSON.stringify({ + customerId: 'customer-1', + provider: 'openai', + providerApiKey: 'sk-provider', + monthlyBudgetCents: 2500, + }), + }, + bindings as any + ); + + expect(createRes.status).toBe(200); + const createBody = await createRes.json() as any; + expect(createBody.customerKey).toMatch(/^wa_/); + + const kvValue = stores.kv.get(`key:${createBody.customerKey}`); + expect(kvValue).toBeTruthy(); + + const listRes = await app.request( + '/api/admin/keys', + { method: 'GET', headers: { 'X-Admin-Secret': 'admin-secret' } }, + bindings as any + ); + + expect(listRes.status).toBe(200); + const listBody = await listRes.json() as any; + expect(listBody.customers).toHaveLength(1); + expect(listBody.customers[0].id).toBe('customer-1'); + expect(listBody.customers[0].customer_key).toBe(createBody.customerKey); + }); +}); diff --git a/packages/backend/src/__tests__/auth.test.ts b/packages/backend/src/__tests__/auth.test.ts new file mode 100644 index 0000000..18173b2 --- /dev/null +++ b/packages/backend/src/__tests__/auth.test.ts @@ -0,0 +1,102 @@ +import { Hono } from 'hono'; +import { authMiddleware } from '../middleware/auth'; +import { createMockBindings } from './test-helpers'; + +const createApp = () => { + type Variables = { + customerId?: string; + provider?: string; + providerApiKey?: string; + isByok: boolean; + }; + const app = new Hono<{ Variables: Variables }>(); + app.use('/secure/*', authMiddleware as any); + app.get('/secure/check', (c) => + c.json({ + customerId: c.get('customerId'), + provider: c.get('provider'), + providerApiKey: c.get('providerApiKey'), + isByok: c.get('isByok'), + }) + ); + return app; +}; + +describe('auth middleware', () => { + it('returns 401 without Authorization header', async () => { + const app = createApp(); + const { bindings } = createMockBindings(); + + const res = await app.request('/secure/check', { method: 'GET' }, bindings as any); + + expect(res.status).toBe(401); + }); + + it('returns 401 for empty Bearer token', async () => { + const app = createApp(); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/secure/check', + { method: 'GET', headers: { Authorization: 'Bearer ' } }, + bindings as any + ); + + expect(res.status).toBe(401); + }); + + it('passes BYOK token and marks isByok=true', async () => { + const app = createApp(); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/secure/check', + { method: 'GET', headers: { Authorization: 'Bearer sk-byok-key' } }, + bindings as any + ); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.isByok).toBe(true); + expect(body.providerApiKey).toBe('sk-byok-key'); + }); + + it('returns 401 for managed key missing in KV', async () => { + const app = createApp(); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/secure/check', + { method: 'GET', headers: { Authorization: 'Bearer wa_missing' } }, + bindings as any + ); + + expect(res.status).toBe(401); + }); + + it('passes valid managed key from KV', async () => { + const app = createApp(); + const { bindings, stores } = createMockBindings(); + stores.kv.set( + 'key:wa_valid', + JSON.stringify({ + customerId: 'cust_1', + provider: 'openai', + providerApiKey: 'sk-managed', + }) + ); + + const res = await app.request( + '/secure/check', + { method: 'GET', headers: { Authorization: 'Bearer wa_valid' } }, + bindings as any + ); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.customerId).toBe('cust_1'); + expect(body.provider).toBe('openai'); + expect(body.providerApiKey).toBe('sk-managed'); + expect(body.isByok).toBe(false); + }); +}); diff --git a/packages/backend/src/__tests__/email.test.ts b/packages/backend/src/__tests__/email.test.ts new file mode 100644 index 0000000..deb6b59 --- /dev/null +++ b/packages/backend/src/__tests__/email.test.ts @@ -0,0 +1,68 @@ +import { Hono } from 'hono'; +import { emailRoute } from '../routes/email'; +import { createMockBindings } from './test-helpers'; + +describe('email route', () => { + it('queues message for valid payload', async () => { + const app = new Hono(); + app.route('/api/email', emailRoute); + + const { bindings, stores } = createMockBindings(); + const res = await app.request( + '/api/email', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: 'user@example.com', + subject: 'Hello', + body: 'Message body', + }), + }, + bindings as any + ); + + expect(res.status).toBe(200); + expect(stores.queue).toHaveLength(1); + }); + + it('returns 400 for invalid email', async () => { + const app = new Hono(); + app.route('/api/email', emailRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/email', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: 'not-an-email', + subject: 'Hello', + body: 'Message body', + }), + }, + bindings as any + ); + + expect(res.status).toBe(400); + }); + + it('returns 400 for missing required fields', async () => { + const app = new Hono(); + app.route('/api/email', emailRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/email', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + bindings as any + ); + + expect(res.status).toBe(400); + }); +}); diff --git a/packages/backend/src/__tests__/escalate.test.ts b/packages/backend/src/__tests__/escalate.test.ts new file mode 100644 index 0000000..3d94af3 --- /dev/null +++ b/packages/backend/src/__tests__/escalate.test.ts @@ -0,0 +1,77 @@ +import { Hono } from 'hono'; +import { escalateRoute } from '../routes/escalate'; +import { createMockBindings } from './test-helpers'; + +describe('escalate route', () => { + it('accepts valid payload and queues escalation message', async () => { + const app = new Hono(); + app.route('/api/escalate', escalateRoute); + + const { bindings, stores } = createMockBindings(); + const res = await app.request( + '/api/escalate', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userEmail: 'user@example.com', + userName: 'User', + transcript: [ + { role: 'user', content: 'Need help' }, + { role: 'assistant', content: 'Sure, I can help.' }, + ], + supportEmail: 'support@example.com', + }), + }, + bindings as any + ); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.ticketId).toBeTruthy(); + expect(stores.queue).toHaveLength(1); + }); + + it('returns 400 when transcript is missing', async () => { + const app = new Hono(); + app.route('/api/escalate', escalateRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/escalate', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userEmail: 'user@example.com', + supportEmail: 'support@example.com', + }), + }, + bindings as any + ); + + expect(res.status).toBe(400); + }); + + it('returns 400 for invalid email', async () => { + const app = new Hono(); + app.route('/api/escalate', escalateRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/escalate', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userEmail: 'invalid-email', + transcript: [{ role: 'user', content: 'Need help' }], + supportEmail: 'support@example.com', + }), + }, + bindings as any + ); + + expect(res.status).toBe(400); + }); +}); diff --git a/packages/backend/src/__tests__/fetch.test.ts b/packages/backend/src/__tests__/fetch.test.ts new file mode 100644 index 0000000..a8f7195 --- /dev/null +++ b/packages/backend/src/__tests__/fetch.test.ts @@ -0,0 +1,142 @@ +import { Hono } from 'hono'; +import { afterEach } from 'vitest'; +import { authMiddleware } from '../middleware/auth'; +import { fetchRoute } from '../routes/fetch'; +import { createMockBindings } from './test-helpers'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('fetch route', () => { + it('returns proxied result for valid URL', async () => { + const app = new Hono(); + app.route('/api/fetch', fetchRoute); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('proxied body', { + status: 201, + statusText: 'Created', + headers: { 'content-type': 'text/plain' }, + }) + ); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com', method: 'GET' }), + }, + bindings as any + ); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.status).toBe(201); + expect(body.body).toBe('proxied body'); + expect(body.contentType).toBe('text/plain'); + }); + + it('blocks localhost URL', async () => { + const app = new Hono(); + app.route('/api/fetch', fetchRoute); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://localhost/test' }), + }, + bindings as any + ); + + expect(res.status).toBe(403); + }); + + it('blocks 127.0.0.1 URL', async () => { + const app = new Hono(); + app.route('/api/fetch', fetchRoute); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://127.0.0.1/test' }), + }, + bindings as any + ); + + expect(res.status).toBe(403); + }); + + it('blocks private IP ranges', async () => { + const app = new Hono(); + app.route('/api/fetch', fetchRoute); + const { bindings } = createMockBindings(); + + const res10 = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://10.1.2.3/test' }), + }, + bindings as any + ); + const res192 = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://192.168.1.20/test' }), + }, + bindings as any + ); + + expect(res10.status).toBe(403); + expect(res192.status).toBe(403); + }); + + it('returns 400 for invalid body', async () => { + const app = new Hono(); + app.route('/api/fetch', fetchRoute); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'GET' }), + }, + bindings as any + ); + + expect(res.status).toBe(400); + }); + + it('returns 401 when auth middleware is used and auth is missing', async () => { + const app = new Hono(); + app.use('/api/*', authMiddleware as any); + app.route('/api/fetch', fetchRoute); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com' }), + }, + bindings as any + ); + + expect(res.status).toBe(401); + }); +}); diff --git a/packages/backend/src/__tests__/health.test.ts b/packages/backend/src/__tests__/health.test.ts new file mode 100644 index 0000000..dd060c2 --- /dev/null +++ b/packages/backend/src/__tests__/health.test.ts @@ -0,0 +1,17 @@ +import { Hono } from 'hono'; +import { healthRoute } from '../routes/health'; +import { createMockBindings } from './test-helpers'; + +describe('health route', () => { + it('GET /api/health returns ok', async () => { + const app = new Hono(); + app.route('/api/health', healthRoute); + + const { bindings } = createMockBindings(); + const res = await app.request('/api/health', { method: 'GET' }, bindings as any); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.status).toBe('ok'); + }); +}); diff --git a/packages/backend/src/__tests__/kb.test.ts b/packages/backend/src/__tests__/kb.test.ts new file mode 100644 index 0000000..74ba9a3 --- /dev/null +++ b/packages/backend/src/__tests__/kb.test.ts @@ -0,0 +1,74 @@ +import { Hono } from 'hono'; +import { kbRoute } from '../routes/kb'; +import { createMockBindings } from './test-helpers'; + +describe('kb route', () => { + it('returns existing KB entry', async () => { + const app = new Hono(); + app.route('/api/kb', kbRoute); + + const { bindings, stores } = createMockBindings(); + stores.kb.set('kb/existing.json', JSON.stringify({ id: 'existing', text: 'hello' })); + + const res = await app.request('/api/kb/existing', { method: 'GET' }, bindings as any); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body).toEqual({ id: 'existing', text: 'hello' }); + }); + + it('returns 404 for missing KB entry', async () => { + const app = new Hono(); + app.route('/api/kb', kbRoute); + + const { bindings } = createMockBindings(); + const res = await app.request('/api/kb/missing', { method: 'GET' }, bindings as any); + + expect(res.status).toBe(404); + }); + + it('returns 401 on PUT without admin secret', async () => { + const app = new Hono(); + app.route('/api/kb', kbRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/kb/doc-1', + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'value' }), + }, + bindings as any + ); + + expect(res.status).toBe(401); + }); + + it('uploads with admin secret and GET returns uploaded content', async () => { + const app = new Hono(); + app.route('/api/kb', kbRoute); + + const { bindings } = createMockBindings(); + + const putRes = await app.request( + '/api/kb/doc-2', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-Secret': 'admin-secret', + }, + body: JSON.stringify({ id: 'doc-2', text: 'uploaded' }), + }, + bindings as any + ); + + expect(putRes.status).toBe(200); + + const getRes = await app.request('/api/kb/doc-2', { method: 'GET' }, bindings as any); + expect(getRes.status).toBe(200); + const body = await getRes.json() as any; + expect(body).toEqual({ id: 'doc-2', text: 'uploaded' }); + }); +}); diff --git a/packages/backend/src/__tests__/test-helpers.ts b/packages/backend/src/__tests__/test-helpers.ts new file mode 100644 index 0000000..259c5f1 --- /dev/null +++ b/packages/backend/src/__tests__/test-helpers.ts @@ -0,0 +1,158 @@ +type CustomerRecord = { + id: string; + customer_key: string; + provider: string; + monthly_budget_cents: number | null; + created_at: string; +}; + +type MockStores = { + kv: Map