diff --git a/README.md b/README.md index b3399c3..27e5e8c 100644 --- a/README.md +++ b/README.md @@ -7,35 +7,122 @@ Embeddable chat widget for [GoClaw](https://goclaw.sh) AI agent gateway. Drop a The widget connects through a **proxy server** that keeps the gateway auth token server-side. The token is never exposed to the browser. ``` -Browser Widget ←→ Proxy Server (:3100) ←→ GoClaw Gateway (:9090) - (no token) (holds token) (validates token) +Browser Widget ←→ Proxy Server (:3100) ←→ GoClaw Gateway + (no token) (injects token) (validates token) ``` ## Features -- **Zero dependencies** — Vanilla TypeScript, ~8KB gzipped +- **Zero dependencies** — Vanilla TypeScript, ~18KB gzipped - **Secure by default** — Auth token never leaves the server - **Shadow DOM isolation** — Styles never leak into or from host page - **Framework-agnostic** — Works with React, Vue, Angular, or plain HTML - **Real-time streaming** — Token-by-token LLM responses via WebSocket - **Theming** — Light, dark, auto, or fully custom via CSS variables -- **Configurable** — Position, size, colors, fonts, avatars, everything - **Auto-reconnect** — Exponential backoff with configurable retries - **Async loading** — Non-blocking snippet pattern (like Intercom) -- **Mobile responsive** — Full-screen on small viewports -- **Accessible** — Keyboard navigation, ARIA labels -## Proxy Server Setup +## Getting Started + +### Step 1: Clone & install + +```bash +git clone https://github.com/nextlevelbuilder/goclaw-plugin-webchat.git +cd goclaw-plugin-webchat +npm install +``` + +### Step 2: Configure the proxy server + +The proxy keeps your GoClaw gateway token server-side. Create a `.env` file: ```bash -cd server/ +cd server cp .env.example .env -# Edit .env: set GOCLAW_URL, GOCLAW_TOKEN, and optionally PROXY_API_KEY +``` + +Edit `server/.env` with your GoClaw credentials: + +```env +# Required: your GoClaw gateway WebSocket URL +GOCLAW_URL=wss://your-workspace.goclaw.sh/ws + +# Required: gateway auth token (never exposed to browser) +GOCLAW_TOKEN=your-gateway-token-here + +# Optional: default agent ID (UUID or slug) +DEFAULT_AGENT_ID=your-agent-id + +# Optional: restrict which origins can connect +# ALLOWED_ORIGINS=https://example.com,https://app.example.com + +# Optional: require API key for backend-to-proxy auth (server-to-server only, never in browser!) +# PROXY_API_KEY=your-secret-key +``` + +Install proxy dependencies: + +```bash npm install +``` + +### Step 3: Start the proxy server + +```bash npm run dev ``` -## Quick Start +You should see: + +``` +[proxy] listening on :3100 +[proxy] upstream: wss://your-workspace.goclaw.sh/ws +[proxy] auth token: configured +``` + +### Step 4: Build the widget + +In a new terminal, from the project root: + +```bash +cd .. +npm run build +``` + +This outputs `dist/goclaw-webchat.umd.js` and `dist/goclaw-webchat.es.js`. + +### Step 5: Add to your website + +```html + + +``` + +Open the page in a browser — the chat widget appears in the bottom-right corner. No token in client code. + +### Step 6: Try the examples + +Open any example file in your browser while the proxy is running: + +```bash +# Serve the project directory +npx serve . + +# Then visit: +# http://localhost:3000/examples/proxy-mode.html — basic proxy setup +# http://localhost:3000/examples/vanilla-basic.html — simplest integration +# http://localhost:3000/examples/vanilla-customized.html — custom theme + API +# http://localhost:3000/examples/async-snippet.html — non-blocking loader +# http://localhost:3000/examples/proxy-with-api-key.html — API key auth +``` + +## Integration Patterns ### Script Tag (simplest) @@ -84,10 +171,8 @@ const widget = init({ theme: 'dark', }); -// Programmatic control widget.open(); widget.send('Hello!'); -widget.close(); widget.destroy(); ``` @@ -118,12 +203,19 @@ app.use(GoClawPlugin, { }); ``` +### Embed Proxy in Express + +If you already run Express/Node.js, embed the proxy directly instead of running a separate process. See `examples/express-embedded-proxy.ts`. + +### Docker Compose + +For production deployment with nginx reverse proxy, see `examples/docker-compose.yml`. + ## Configuration | Option | Type | Default | Description | |--------|------|---------|-------------| | `url` | `string` | *required* | Proxy server WebSocket URL (`wss://...`) | -| `apiKey` | `string` | — | API key for proxy authentication | | `userId` | `string` | auto-generated | User identifier | | `agentId` | `string` | — | Specific agent to chat with | | `sessionId` | `string` | — | Resume a previous session | @@ -140,6 +232,20 @@ app.use(GoClawPlugin, { | `maxReconnectAttempts` | `number` | `10` | Max reconnection attempts | | `zIndex` | `number` | `999999` | CSS z-index | +## Proxy Server Configuration + +| Env Variable | Required | Default | Description | +|-------------|----------|---------|-------------| +| `GOCLAW_URL` | Yes | — | GoClaw gateway WebSocket URL | +| `GOCLAW_TOKEN` | Yes | — | Gateway auth token (kept server-side) | +| `PORT` | No | `3100` | Proxy server port | +| `DEFAULT_AGENT_ID` | No | — | Default agent for `chat.send` | +| `ALLOWED_ORIGINS` | No | `*` | Comma-separated allowed origins | +| `MAX_CONNECTIONS_PER_IP` | No | `10` | Per-IP connection limit | +| `TRUST_PROXY` | No | `false` | Trust X-Forwarded-For headers | +| `PROXY_API_KEY` | No | — | Backend-to-proxy auth (server-to-server only) | +| `LOG_LEVEL` | No | `info` | `debug` / `info` / `warn` / `error` | + ## Custom Themes ```js @@ -205,10 +311,17 @@ GoClaw.init({ ## Development ```bash +npm install # Widget deps +npm run dev # Widget dev server with HMR +npm run build # Production build +npm run lint # Type-check + +# Proxy server +cd server npm install -npm run dev # Dev server with HMR -npm run build # Production build -npm run lint # Type-check +npm run dev # Start proxy (tsx watch) +npm run build # Compile to dist/ +npm start # Run compiled proxy ``` ## License diff --git a/docs/code-standards.md b/docs/code-standards.md index a2e454d..ea3726a 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -22,20 +22,28 @@ - CSS custom properties for theming - Event-driven WebSocket communication - Builder pattern for configuration (single config object) +- Proxy-only: widget never handles auth tokens, proxy injects them server-side + +### GoClaw Protocol v3 Conventions +- Parameter names use **camelCase**: `agentId`, `sessionKey`, `runId` +- Chat method: `chat.send` with `{ message, agentId, stream }` (not `content`) +- Events wrapped: `{ event: "agent", payload: { type: "chunk", payload: { content } } }` +- Connect method: `connect` with `{ protocol: 3, user_id }` — proxy adds `token` ### Security - HTML escaping for all user content - No `eval()` or `innerHTML` with raw user input - Markdown renderer escapes HTML before processing - XSS-safe link rendering (only http/https) +- No client-side tokens or API keys — all auth is server-side via proxy ## Proxy Server (server/) ### Language & Tooling - TypeScript strict mode - Node.js ESM (type: module in package.json) -- Runtime dependency: `ws` (WebSocket library v8.18+) -- Dev dependencies: TypeScript, tsx (development server), @types/node, @types/ws +- Runtime dependencies: `ws` (WebSocket v8.18+), `dotenv` (env loading) +- Dev dependencies: TypeScript, tsx (dev server), @types/node, @types/ws ### File Naming - kebab-case for all source files @@ -49,15 +57,22 @@ ### Architecture Patterns - Config-driven initialization (environment variables via `proxy-config.ts`) -- Per-IP connection tracking with exponential backoff +- `dotenv/config` loaded at entry point for `.env` file support +- Per-IP connection tracking +- Per-session message rate limiting (60 msg/min) +- WebSocket frame interception: injects `GOCLAW_TOKEN` into `connect`, `DEFAULT_AGENT_ID` into `chat.send` - WebSocket frame buffering until upstream connection ready +- Upstream response sanitization (strips token fields) - Graceful shutdown with drain timeout - Non-JSON frames dropped silently ### Security -- Auth token stored server-side only, never sent to client +- Auth token (`GOCLAW_TOKEN`) stored server-side only, never sent to client +- `PROXY_API_KEY` is backend-to-backend only (Express, nginx → proxy), never from browser - Origin validation via `ALLOWED_ORIGINS` environment variable (empty = allow all) - Per-IP connection limits via `MAX_CONNECTIONS_PER_IP` (default: 10) -- TRUST_PROXY flag for reverse proxy (nginx, Cloudflare) deployments +- Per-session rate limiting: 60 messages per minute +- `TRUST_PROXY` flag for reverse proxy (nginx, Cloudflare) deployments - Max frame size: 512KB - Upstream gateway URL not included in health endpoint responses +- Connect timeout: 10 seconds for upstream connection diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 126032e..34a0e23 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -6,7 +6,7 @@ Embeddable JavaScript chat widget allowing website owners to add AI chat powered ## Tech Stack - **Language**: TypeScript (strict mode) - **Build**: Vite 6 (library mode), ESBuild minification -- **Output**: UMD + ESM bundles (~8KB gzipped) +- **Output**: UMD + ESM bundles (~18KB gzipped) - **UI**: Vanilla DOM + Shadow DOM for style isolation - **Styling**: CSS custom properties for theming - **Protocol**: GoClaw WebSocket Protocol v3 (req/res/event frames) @@ -15,9 +15,9 @@ Embeddable JavaScript chat widget allowing website owners to add AI chat powered ### Widget (src/) - `src/index.ts` — Entry point, `init()` function, window auto-attach -- `src/websocket-client.ts` — WebSocket connection, auth, RPC, event handling, reconnection (supports proxy mode) +- `src/websocket-client.ts` — WebSocket connection, auth, RPC, event handling, reconnection (proxy-only) - `src/chat-widget.ts` — Shadow DOM UI, message rendering, input handling -- `src/types.ts` — All TypeScript interfaces (includes `proxyUrl` config option) +- `src/types.ts` — All TypeScript interfaces (proxy-only config, no client-side tokens) - `src/markdown-renderer.ts` — Lightweight markdown-to-HTML - `src/svg-icons.ts` — Inline SVG icons - `src/styles/theme-variables.ts` — Theme system (light/dark/auto/custom) @@ -26,12 +26,22 @@ Embeddable JavaScript chat widget allowing website owners to add AI chat powered - `src/wrappers/vue-wrapper.ts` — Vue 3 plugin ### Proxy Server (server/) -- `server/src/index.ts` — Server entry point, HTTP + WebSocket listener +- `server/src/index.ts` — Server entry point, loads dotenv, starts HTTP + WebSocket - `server/src/proxy-config.ts` — Configuration from environment variables -- `server/src/proxy-server.ts` — HTTP + WebSocket server with origin validation, per-IP limits, graceful shutdown -- `server/src/websocket-proxy-session.ts` — Single proxy session: intercepts WS `connect` frame to inject gateway token, buffers upstream messages +- `server/src/proxy-server.ts` — HTTP + WebSocket server with origin validation, per-IP limits, API key auth, graceful shutdown +- `server/src/websocket-proxy-session.ts` — Single proxy session: intercepts `connect` frame to inject gateway token, injects `DEFAULT_AGENT_ID` into `chat.send`, buffers upstream messages, rate limits per session - `server/src/connection-tracker.ts` — Per-IP connection rate limiting +### Examples (examples/) +- `examples/proxy-mode.html` — Basic proxy setup with architecture walkthrough +- `examples/vanilla-basic.html` — Simplest integration +- `examples/vanilla-customized.html` — Custom theme, bottom-left, programmatic API +- `examples/async-snippet.html` — Non-blocking Intercom-style loader +- `examples/proxy-with-api-key.html` — Backend-to-proxy API key auth +- `examples/express-embedded-proxy.ts` — Embed proxy in Express app +- `examples/docker-compose.yml` — Production nginx + proxy deployment +- `examples/examples.css` — Shared CSS for example pages + ## Distribution ### Widget @@ -43,12 +53,13 @@ Embeddable JavaScript chat widget allowing website owners to add AI chat powered ### Proxy Server - Docker-friendly Node.js server (TypeScript) - Can be self-hosted or deployed to any Node.js-compatible platform -- Dependency: `ws` (WebSocket library, single production dependency) +- Dependencies: `ws` (WebSocket library), `dotenv` (env loading) ## Key Decisions - Shadow DOM over iframe: Better performance, same-origin access, no CORS issues - Vanilla TS over framework: Zero deps, universal compatibility - CSS custom properties: Runtime theming without rebuild - Exponential backoff reconnect: Resilient WS connections -- Backend proxy for production: Keeps auth tokens server-side, prevents token exposure in client code -- Dual-mode architecture: Direct mode for dev/testing, proxy mode for production security +- Proxy-only architecture: All auth tokens stay server-side, no client-side token exposure +- No client-side API key: `PROXY_API_KEY` is backend-to-backend only, never sent from browser +- GoClaw Protocol params: `agentId` (camelCase), `message` (not `content`), nested `event:agent` wrapper diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 961d464..32e2d65 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -20,17 +20,18 @@ │ │ └───────────────────────────┘ │ │ │ └───────────────────────────────────┘ │ └─────────────────────────────────────────┘ - │ WebSocket + │ WebSocket (no token) ▼ -┌─────────────────────────────────────────┐ (optional) +┌─────────────────────────────────────────┐ │ WebChat Proxy Server │ │ ┌──────────┐ ┌───────────────────┐ │ │ │ WS Server │ │ Token Injection │ │ │ │ /ws :3100 │──│ (connect frame) │ │ │ └──────────┘ └───────────────────┘ │ │ Origin validation │ Per-IP limits │ +│ API key auth (opt) │ agentId inject │ └─────────────────────────────────────────┘ - │ WebSocket (with token) + │ WebSocket (with GOCLAW_TOKEN) ▼ ┌─────────────────────────────────────────┐ │ GoClaw Gateway │ @@ -41,31 +42,55 @@ │ │ │ │ │ ┌────┴────┐ ┌─────┴──────┐ │ │ │ Auth │ │ LLM Provider│ │ -│ │ Pairing │ │ (13+ LLMs) │ │ +│ │ API Keys│ │ (13+ LLMs) │ │ │ └─────────┘ └────────────┘ │ └─────────────────────────────────────────┘ ``` -### Connection Modes - -1. **Direct mode** (`url` + `token`): Widget connects directly to GoClaw Gateway. Token exposed in client-side JS. Suitable for development or trusted environments. -2. **Proxy mode** (`proxyUrl`): Widget connects to proxy server, which holds the token server-side and injects it into the WS `connect` frame. Recommended for production. - ## Data Flow -1. **Init**: `GoClaw.init(config)` → creates ChatWidget → attaches Shadow DOM → connects WebSocket (to `proxyUrl` if set, else `url`) -2. **Auth**: WS open → sends `connect` frame (token injected by proxy in proxy mode, or sent directly) → server validates → connected state -3. **Send**: User types → `chat.send` RPC → server runs agent → streams events back -4. **Stream**: `run.started` → `chunk` (tokens) → `tool.call/result` → `run.completed` +1. **Init**: `GoClaw.init({ url })` → creates ChatWidget → attaches Shadow DOM → connects WebSocket to proxy +2. **Auth**: WS open → sends `connect` frame (no token) → proxy intercepts and injects `GOCLAW_TOKEN` → forwards to gateway → connected +3. **Send**: User types → `chat.send` RPC with `{ message, agentId, stream }` → proxy injects `DEFAULT_AGENT_ID` if not set → forwards to gateway +4. **Stream**: `event:agent { type: "run.started" }` → `{ type: "chunk", payload: { content } }` → `{ type: "run.completed" }` 5. **Reconnect**: WS close (unclean) → exponential backoff → re-authenticate → resume +## GoClaw WebSocket Protocol v3 + +### Request Frame +```json +{ "type": "req", "id": "req_1", "method": "chat.send", "params": { "message": "hello", "agentId": "uuid-or-slug", "stream": true } } +``` + +### Response Frame +```json +{ "type": "res", "id": "req_1", "ok": true, "payload": { "runId": "...", "content": "..." } } +``` + +### Event Frame (agent events are nested) +```json +{ "type": "event", "event": "agent", "payload": { "type": "chunk", "agentId": "kha-dao", "runId": "...", "payload": { "content": "token text" } } } +``` + +### Agent Event Types +| Event | Description | +|-------|-------------| +| `run.started` | Agent started processing | +| `activity` | Agent phase change (thinking, acting) | +| `thinking` | Thinking tokens (internal reasoning) | +| `chunk` | Response content tokens | +| `tool.call` | Tool invocation started | +| `tool.result` | Tool execution completed | +| `run.completed` | Agent finished, includes final content + usage | +| `run.failed` | Agent failed with error | + ## Module Dependencies ### Widget ``` index.ts └── chat-widget.ts - ├── websocket-client.ts (uses proxyUrl or direct url+token) + ├── websocket-client.ts (connects to proxy url) │ └── types.ts ├── markdown-renderer.ts ├── svg-icons.ts @@ -76,7 +101,7 @@ index.ts ### Proxy Server ``` -index.ts +index.ts (loads dotenv) ├── proxy-config.ts (reads env vars) ├── proxy-server.ts (HTTP + WebSocket server) │ ├── websocket-proxy-session.ts (per-connection handler) @@ -86,28 +111,40 @@ index.ts ## Security Model -### Direct Mode (Development) -``` -Browser (holds token) → GoClaw Gateway -- Widget config: { url, token } -- Risk: Token visible in client-side JS -- Use case: Local dev, trusted environments only -``` - -### Proxy Mode (Production - Recommended) +### Proxy-Only Architecture (Production) ``` Browser (no token) → Proxy Server (holds token) → GoClaw Gateway -- Widget config: { proxyUrl } -- Proxy config: GOCLAW_TOKEN env var -- Benefits: Token never exposed to client, origin validation, rate limiting -- Deployment: Self-host or use managed proxy service +- Widget config: { url: 'wss://proxy/ws' } +- Proxy env: GOCLAW_TOKEN, DEFAULT_AGENT_ID +- Token never exposed to client ``` -## Proxy Security Features -- **Token injection**: Proxy intercepts WS `connect` frame and injects gateway token server-side -- **Origin validation**: Rejects connections from unauthorized origins (ALLOWED_ORIGINS env) -- **Rate limiting**: Max connections per IP (MAX_CONNECTIONS_PER_IP env, default 10) -- **Reverse proxy support**: TRUST_PROXY flag for X-Forwarded-For header when behind nginx/Cloudflare +### Dual Auth Layers +| Layer | Mechanism | Purpose | +|-------|-----------|---------| +| **Client → Proxy** | `PROXY_API_KEY` (optional) | Control who can use the proxy | +| **Proxy → Gateway** | `GOCLAW_TOKEN` (required) | Authenticate proxy to GoClaw | + +### Proxy Security Features +- **Token injection**: Intercepts WS `connect` frame, injects gateway token server-side +- **agentId injection**: Injects `DEFAULT_AGENT_ID` into `chat.send` if client omits it +- **Token stripping**: Removes token fields from upstream responses (defense in depth) +- **API key auth**: Optional `PROXY_API_KEY` — clients authenticate via `?apiKey=xxx` or `X-API-Key` header +- **Origin validation**: Rejects connections from unauthorized origins (`ALLOWED_ORIGINS`) +- **Rate limiting**: Max connections per IP (`MAX_CONNECTIONS_PER_IP`, default 10) +- **Rate limiting (per-session)**: 60 messages/minute per WebSocket session +- **Reverse proxy support**: `TRUST_PROXY` for X-Forwarded-For behind nginx/Cloudflare - **Frame size limit**: 512KB max WebSocket frame -- **Silent filtering**: Non-JSON frames dropped without error -- **Secure defaults**: Health endpoint excludes upstream URL +- **Non-JSON filtering**: Non-JSON frames silently dropped + +## Examples + +| Example | File | Description | +|---------|------|-------------| +| Proxy mode | `examples/proxy-mode.html` | Basic proxy setup, architecture walkthrough | +| Basic | `examples/vanilla-basic.html` | Simplest integration | +| Customized | `examples/vanilla-customized.html` | Custom theme, bottom-left, programmatic API | +| Async snippet | `examples/async-snippet.html` | Non-blocking Intercom-style loader | +| API key auth | `examples/proxy-with-api-key.html` | Proxy with `PROXY_API_KEY` | +| Express embedded | `examples/express-embedded-proxy.ts` | Embed proxy in Express app | +| Docker compose | `examples/docker-compose.yml` | Production nginx + proxy setup | diff --git a/examples/Dockerfile.proxy b/examples/Dockerfile.proxy new file mode 100644 index 0000000..e209d0c --- /dev/null +++ b/examples/Dockerfile.proxy @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +COPY server/package*.json ./ +RUN npm ci --production=false +COPY server/src ./src +COPY server/tsconfig.json ./ +RUN npx tsc +CMD ["node", "dist/index.js"] diff --git a/examples/async-snippet.html b/examples/async-snippet.html index 5cec186..52c040b 100644 --- a/examples/async-snippet.html +++ b/examples/async-snippet.html @@ -3,36 +3,53 @@
-This loading pattern is non-blocking — the script loads asynchronously and processes queued commands. Identical to how Intercom, Drift, and similar widgets load.
+Non-blocking script loader pattern. Identical to how Intercom, Drift, and similar widgets load.
+<script>
- // GoClaw WebChat async loader
(function(w,d,s,u){
w.__goclaw_queue=w.__goclaw_queue||[];
- w.GoClaw=w.GoClaw||{init:function(){w.__goclaw_queue.push(['init',arguments[0]])}};
- var f=d.getElementsByTagName(s)[0],j=d.createElement(s);
- j.async=true;j.src=u;f.parentNode.insertBefore(j,f);
- })(window,document,'script','https://cdn.example.com/goclaw-webchat.umd.js');
+ w.GoClaw=w.GoClaw||{
+ init:function(){
+ w.__goclaw_queue.push(['init',arguments[0]])
+ }
+ };
+ var f=d.getElementsByTagName(s)[0],
+ j=d.createElement(s);
+ j.async=true;
+ j.src=u;
+ f.parentNode.insertBefore(j,f);
+ })(window,document,'script',
+ 'https://cdn.example.com/goclaw-webchat.umd.js');
GoClaw.init({
- url: 'wss://your-server.com/ws',
- token: 'your-token',
+ url: 'wss://proxy.example.com/ws',
title: 'Chat with us',
theme: 'auto',
});
</script>
-
+ The GoClaw chat widget connects to /ws on this same server.
+ No separate proxy process needed.
This example uses a proxy server to keep the auth token server-side. - No token appears in client-side code.
+The widget connects through a proxy server that keeps the gateway auth token server-side. + No token ever appears in client-side code or browser DevTools.
+Browser Widget ←→ Proxy Server (:3100) ←→ GoClaw Gateway (:9090)
- (no token) (holds token) (validates token)
+ ws://localhost:3100/ws
+ connect frame (no token)
+ GOCLAW_TOKEN from env, forwards to gateway
+ # 1. Configure the proxy server
@@ -27,30 +61,30 @@ Setup
cp .env.example .env
# Edit .env: set GOCLAW_URL and GOCLAW_TOKEN
-# 2. Start the proxy
+# 2. Install deps & start the proxy
+npm install
npm run dev
-# 3. Open this page in a browser
+# 3. Build the widget (from project root)
+cd ..
+npm run build
+
+# 4. Open this page in a browser
- GoClaw.init({
- url: 'wss://your-goclaw-server.com/ws', // fallback for direct mode
- proxyUrl: 'ws://localhost:3100/ws', // proxy handles auth
+ url: 'ws://localhost:3100/ws', // proxy server — no token needed!
title: 'Chat with us',
theme: 'auto',
});
-
+
+
+