From ca6a9123e72d4b3a549f4658acadb2c31c79aac7 Mon Sep 17 00:00:00 2001 From: Lembot Date: Fri, 12 Dec 2025 04:55:56 -0500 Subject: [PATCH 1/7] feat: add credential server for secure API key storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a credential server that stores API keys server-side, eliminating the need to store credentials in the browser. Key changes: - New credential server with SQLite storage and encrypted credentials - Setup page for API key entry (/setup) - Proxy endpoint for API requests (/proxy) - HTTP-only cookies for session management - ProxiedAnthropicProvider for routing through credential server - INIT command for worker to configure credential server URL - Docker configuration for credential server deployment Usage: - Deploy credential server with CREDENTIAL_ENCRYPTION_SECRET env var - Initialize ai-delegate with credentialServerUrl option - Users visit /setup to enter their API key once - Subsequent requests use session cookies automatically Closes #2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docker/credential-server/Dockerfile | 46 +++ docker/credential-server/server.js | 24 ++ package-lock.json | 435 ++++++++++++++++++++- package.json | 9 +- src/core/AiDelegate.ts | 4 +- src/core/AiDelegateCore.ts | 31 +- src/credential-server/db.ts | 244 ++++++++++++ src/credential-server/index.ts | 439 ++++++++++++++++++++++ src/providers/ProxiedAnthropicProvider.ts | 302 +++++++++++++++ src/types/index.ts | 8 + src/worker/WorkerWrapper.ts | 11 +- src/worker/types.ts | 6 + src/worker/worker.ts | 60 ++- webpack.config.js | 38 ++ 14 files changed, 1633 insertions(+), 24 deletions(-) create mode 100644 docker/credential-server/Dockerfile create mode 100644 docker/credential-server/server.js create mode 100644 src/credential-server/db.ts create mode 100644 src/credential-server/index.ts create mode 100644 src/providers/ProxiedAnthropicProvider.ts diff --git a/docker/credential-server/Dockerfile b/docker/credential-server/Dockerfile new file mode 100644 index 0000000..2bf48b3 --- /dev/null +++ b/docker/credential-server/Dockerfile @@ -0,0 +1,46 @@ +FROM node:24-alpine AS base + +WORKDIR /app + +# Install build dependencies for better-sqlite3 +RUN apk add --no-cache python3 make g++ + +# Copy source and install dependencies +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Production stage +FROM node:24-alpine AS production + +WORKDIR /app + +# Install runtime dependencies for better-sqlite3 +RUN apk add --no-cache python3 make g++ + +# Copy only what's needed for production +COPY --from=base /app/package*.json ./ +COPY --from=base /app/node_modules ./node_modules +COPY --from=base /app/dist ./dist +COPY --from=base /app/docker/credential-server/server.js ./server.js + +# Create data directory for SQLite database +RUN mkdir -p /data + +# Environment variables +ENV PORT=3001 +ENV DB_PATH=/data/credentials.db +# CREDENTIAL_ENCRYPTION_SECRET must be provided at runtime + +EXPOSE 3001 + +# Volume for persistent data +VOLUME ["/data"] + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3001/health || exit 1 + +CMD ["node", "server.js"] diff --git a/docker/credential-server/server.js b/docker/credential-server/server.js new file mode 100644 index 0000000..2af1779 --- /dev/null +++ b/docker/credential-server/server.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +/** + * AI Delegate Credential Server + * + * A secure server for storing encrypted API credentials and proxying + * requests to AI providers. Credentials never leave the server. + * + * Required environment variables: + * - CREDENTIAL_ENCRYPTION_SECRET: Secret key for encrypting stored credentials + * + * Optional environment variables: + * - PORT: Server port (default: 3001) + * - DB_PATH: Path to SQLite database (default: ./credentials.db) + */ + +// Check for required environment variable +if (!process.env.CREDENTIAL_ENCRYPTION_SECRET) { + console.error('ERROR: CREDENTIAL_ENCRYPTION_SECRET environment variable is required'); + console.error('Generate one with: openssl rand -hex 32'); + process.exit(1); +} + +// Start the credential server +require('./dist/credential-server/index.js').startServer(); diff --git a/package-lock.json b/package-lock.json index 7b757f6..4d5af2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.2.2", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.71.0" + "@anthropic-ai/sdk": "^0.71.0", + "better-sqlite3": "^12.5.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.10.1", "rimraf": "^6.0.1", "ts-loader": "^9.5.4", @@ -135,6 +137,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -495,6 +507,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.8.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", @@ -505,6 +537,40 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-sqlite3": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.5.0.tgz", + "integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -553,6 +619,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -598,6 +688,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -672,6 +768,39 @@ "node": ">= 8" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.263", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.263.tgz", @@ -679,6 +808,15 @@ "dev": true, "license": "ISC" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -780,6 +918,15 @@ "node": ">=0.8.x" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -814,6 +961,12 @@ "node": ">= 4.9.1" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -851,6 +1004,12 @@ "flat": "cli.js" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -861,6 +1020,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", @@ -916,6 +1081,26 @@ "node": ">= 0.4" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -936,6 +1121,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/interpret": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", @@ -1151,6 +1348,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", @@ -1167,6 +1376,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -1177,6 +1395,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -1184,6 +1414,18 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -1191,6 +1433,15 @@ "dev": true, "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -1314,6 +1565,42 @@ "node": ">=8" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1324,6 +1611,35 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -1415,7 +1731,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -1456,7 +1771,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1511,6 +1825,51 @@ "node": ">=8" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1542,6 +1901,24 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -1582,6 +1959,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/terser": { "version": "5.44.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", @@ -1676,6 +2081,18 @@ "webpack": "^5.0.0" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1729,6 +2146,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -1894,6 +2317,12 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "dev": true, "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" } } } diff --git a/package.json b/package.json index 00ba3a7..15b4430 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,11 @@ "types": "./dist/server/index.d.ts", "require": "./dist/server/index.js", "default": "./dist/server/index.js" + }, + "./credential-server": { + "types": "./dist/credential-server/index.d.ts", + "require": "./dist/credential-server/index.js", + "default": "./dist/credential-server/index.js" } }, "files": [ @@ -46,6 +51,7 @@ "node": ">=24.0.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.10.1", "rimraf": "^6.0.1", "ts-loader": "^9.5.4", @@ -54,6 +60,7 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "@anthropic-ai/sdk": "^0.71.0" + "@anthropic-ai/sdk": "^0.71.0", + "better-sqlite3": "^12.5.0" } } diff --git a/src/core/AiDelegate.ts b/src/core/AiDelegate.ts index 6643527..07dbf2c 100644 --- a/src/core/AiDelegate.ts +++ b/src/core/AiDelegate.ts @@ -25,6 +25,7 @@ export class AiDelegate { private worker: WorkerWrapper; private workerInitialized = false; private endpoint: string | null = null; + private credentialServerUrl: string | undefined = undefined; private initPromise: Promise | null = null; private constructor() { @@ -63,6 +64,7 @@ export class AiDelegate { } this.endpoint = endpoint; + this.credentialServerUrl = options.credentialServerUrl; // If init is called multiple times, just return the existing promise if (this.initPromise) { @@ -89,7 +91,7 @@ export class AiDelegate { } const workerUrl = this.getWorkerUrl(); - await this.worker.initialize(workerUrl); + await this.worker.initialize(workerUrl, this.credentialServerUrl); // Set up prompt handler for worker this.setupPromptHandler(); diff --git a/src/core/AiDelegateCore.ts b/src/core/AiDelegateCore.ts index 75afcf0..6e51f86 100644 --- a/src/core/AiDelegateCore.ts +++ b/src/core/AiDelegateCore.ts @@ -13,6 +13,7 @@ import { import { WeightMapper } from './WeightMapper'; import { ConfigManager } from '../config/ConfigManager'; import { AnthropicProvider } from '../providers/AnthropicProvider'; +import { ProxiedAnthropicProvider } from '../providers/ProxiedAnthropicProvider'; import { Provider } from '../providers/Provider'; /** @@ -39,9 +40,11 @@ export class AiDelegateCore { private credentials?: Credentials; private isInitialized = false; private promptHandler?: PromptHandler; + private credentialServerUrl?: string; - private constructor(configManager: ConfigManager) { + private constructor(configManager: ConfigManager, credentialServerUrl?: string) { this.configManager = configManager; + this.credentialServerUrl = credentialServerUrl; } /** @@ -55,17 +58,33 @@ export class AiDelegateCore { /** * Get the singleton instance * @param configManager - ConfigManager instance (required for first initialization) + * @param credentialServerUrl - Optional URL of the credential server for proxied mode */ - static getInstance(configManager?: ConfigManager): AiDelegateCore { + static getInstance(configManager?: ConfigManager, credentialServerUrl?: string): AiDelegateCore { if (!AiDelegateCore.instance) { if (!configManager) { throw new Error('ConfigManager required for first initialization'); } - AiDelegateCore.instance = new AiDelegateCore(configManager); + AiDelegateCore.instance = new AiDelegateCore(configManager, credentialServerUrl); } return AiDelegateCore.instance; } + /** + * Check if running in proxied mode (credential server) + */ + isProxiedMode(): boolean { + return !!this.credentialServerUrl; + } + + /** + * Get the setup URL for the credential server (proxied mode only) + */ + getCredentialServerSetupUrl(): string | null { + if (!this.credentialServerUrl) return null; + return `${this.credentialServerUrl}/setup`; + } + /** * Initialize from stored configuration (called automatically on first use) * Loads metadata only - credentials remain locked until unlocked @@ -329,6 +348,12 @@ export class AiDelegateCore { private createProvider(providerType: ProviderType): Provider { switch (providerType) { case 'anthropic': + // Use proxied provider if credential server URL is configured + if (this.credentialServerUrl) { + return new ProxiedAnthropicProvider({ + credentialServerUrl: this.credentialServerUrl + }); + } return new AnthropicProvider(); case 'openai': throw new Error('OpenAI provider not yet implemented'); diff --git a/src/credential-server/db.ts b/src/credential-server/db.ts new file mode 100644 index 0000000..4332abe --- /dev/null +++ b/src/credential-server/db.ts @@ -0,0 +1,244 @@ +/** + * Database module for credential storage + * Uses SQLite with encrypted credentials at rest + */ + +import Database from 'better-sqlite3'; +import * as crypto from 'crypto'; +import * as path from 'path'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const KEY_LENGTH = 32; +const ITERATIONS = 100000; + +export interface User { + id: string; + encryptedApiKey: string; + createdAt: number; + updatedAt: number; +} + +export interface Session { + id: string; + userId: string; + createdAt: number; + expiresAt: number; +} + +export class CredentialDatabase { + private db: Database.Database; + private encryptionKey: Buffer; + + constructor(dbPath?: string, encryptionSecret?: string) { + const resolvedPath = dbPath || path.join(process.cwd(), 'credentials.db'); + const secret = encryptionSecret || process.env.CREDENTIAL_ENCRYPTION_SECRET; + + if (!secret) { + throw new Error('CREDENTIAL_ENCRYPTION_SECRET environment variable is required'); + } + + // Derive encryption key from secret + this.encryptionKey = crypto.pbkdf2Sync( + secret, + 'ai-delegate-salt', // Static salt for key derivation + ITERATIONS, + KEY_LENGTH, + 'sha256' + ); + + this.db = new Database(resolvedPath); + this.initSchema(); + } + + private initSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + encrypted_api_key TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + `); + } + + /** + * Encrypt a value using AES-256-GCM + */ + private encrypt(plaintext: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, this.encryptionKey, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final() + ]); + + const authTag = cipher.getAuthTag(); + + // Format: iv (12 bytes) + authTag (16 bytes) + encrypted data + const result = Buffer.concat([iv, authTag, encrypted]); + return result.toString('base64'); + } + + /** + * Decrypt a value encrypted with AES-256-GCM + */ + private decrypt(ciphertext: string): string { + const data = Buffer.from(ciphertext, 'base64'); + + const iv = data.subarray(0, IV_LENGTH); + const authTag = data.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = data.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + + const decipher = crypto.createDecipheriv(ALGORITHM, this.encryptionKey, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]); + + return decrypted.toString('utf8'); + } + + /** + * Create a new user with an encrypted API key + */ + createUser(apiKey: string): User { + const id = crypto.randomUUID(); + const now = Date.now(); + const encryptedApiKey = this.encrypt(apiKey); + + const stmt = this.db.prepare(` + INSERT INTO users (id, encrypted_api_key, created_at, updated_at) + VALUES (?, ?, ?, ?) + `); + + stmt.run(id, encryptedApiKey, now, now); + + return { + id, + encryptedApiKey, + createdAt: now, + updatedAt: now + }; + } + + /** + * Get decrypted API key for a user + */ + getApiKey(userId: string): string | null { + const stmt = this.db.prepare('SELECT encrypted_api_key FROM users WHERE id = ?'); + const row = stmt.get(userId) as { encrypted_api_key: string } | undefined; + + if (!row) { + return null; + } + + return this.decrypt(row.encrypted_api_key); + } + + /** + * Update a user's API key + */ + updateApiKey(userId: string, newApiKey: string): boolean { + const encryptedApiKey = this.encrypt(newApiKey); + const now = Date.now(); + + const stmt = this.db.prepare(` + UPDATE users SET encrypted_api_key = ?, updated_at = ? WHERE id = ? + `); + + const result = stmt.run(encryptedApiKey, now, userId); + return result.changes > 0; + } + + /** + * Create a new session for a user + */ + createSession(userId: string, ttlMs: number = 7 * 24 * 60 * 60 * 1000): Session { + const id = crypto.randomBytes(32).toString('hex'); + const now = Date.now(); + const expiresAt = now + ttlMs; + + const stmt = this.db.prepare(` + INSERT INTO sessions (id, user_id, created_at, expires_at) + VALUES (?, ?, ?, ?) + `); + + stmt.run(id, userId, now, expiresAt); + + return { + id, + userId, + createdAt: now, + expiresAt + }; + } + + /** + * Get session by ID (returns null if expired or not found) + */ + getSession(sessionId: string): Session | null { + const stmt = this.db.prepare(` + SELECT id, user_id, created_at, expires_at FROM sessions + WHERE id = ? AND expires_at > ? + `); + + const row = stmt.get(sessionId, Date.now()) as { + id: string; + user_id: string; + created_at: number; + expires_at: number; + } | undefined; + + if (!row) { + return null; + } + + return { + id: row.id, + userId: row.user_id, + createdAt: row.created_at, + expiresAt: row.expires_at + }; + } + + /** + * Delete a session + */ + deleteSession(sessionId: string): boolean { + const stmt = this.db.prepare('DELETE FROM sessions WHERE id = ?'); + const result = stmt.run(sessionId); + return result.changes > 0; + } + + /** + * Delete all expired sessions + */ + cleanupExpiredSessions(): number { + const stmt = this.db.prepare('DELETE FROM sessions WHERE expires_at <= ?'); + const result = stmt.run(Date.now()); + return result.changes; + } + + /** + * Close the database connection + */ + close(): void { + this.db.close(); + } +} diff --git a/src/credential-server/index.ts b/src/credential-server/index.ts new file mode 100644 index 0000000..ed56ba1 --- /dev/null +++ b/src/credential-server/index.ts @@ -0,0 +1,439 @@ +/** + * Credential Server + * + * A secure server that stores encrypted API credentials and proxies + * requests to AI providers. Credentials never leave the server. + */ + +import * as http from 'http'; +import * as https from 'https'; +import { URL } from 'url'; +import { CredentialDatabase } from './db'; + +const PORT = parseInt(process.env.PORT || '3001', 10); +const COOKIE_NAME = 'aid_session'; +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +// Anthropic API endpoint +const ANTHROPIC_API_BASE = 'https://api.anthropic.com'; + +let db: CredentialDatabase; + +/** + * Parse cookies from request headers + */ +function parseCookies(req: http.IncomingMessage): Record { + const cookies: Record = {}; + const cookieHeader = req.headers.cookie; + + if (cookieHeader) { + cookieHeader.split(';').forEach(cookie => { + const [name, ...rest] = cookie.trim().split('='); + cookies[name] = rest.join('='); + }); + } + + return cookies; +} + +/** + * Set HTTP-only secure cookie + */ +function setSessionCookie(res: http.ServerResponse, sessionId: string): void { + const isProduction = process.env.NODE_ENV === 'production'; + const cookieOptions = [ + `${COOKIE_NAME}=${sessionId}`, + 'HttpOnly', + 'Path=/', + `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, + 'SameSite=Strict' + ]; + + if (isProduction) { + cookieOptions.push('Secure'); + } + + res.setHeader('Set-Cookie', cookieOptions.join('; ')); +} + +/** + * Clear session cookie + */ +function clearSessionCookie(res: http.ServerResponse): void { + res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Strict`); +} + +/** + * Send JSON response + */ +function sendJson(res: http.ServerResponse, statusCode: number, data: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +} + +/** + * Read request body as string + */ +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +/** + * Handle setup page (GET /setup) + */ +function handleSetupPage(_req: http.IncomingMessage, res: http.ServerResponse): void { + const html = ` + + + + + AI Delegate - Setup + + + +
+

AI Delegate Setup

+

Enter your Anthropic API key to get started. Your key will be encrypted and stored securely on this server.

+
+ + + +
+

+

+
+ + +`; + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(html); +} + +/** + * Handle setup POST (POST /setup) + */ +async function handleSetupPost(req: http.IncomingMessage, res: http.ServerResponse): Promise { + try { + const body = await readBody(req); + const { apiKey } = JSON.parse(body); + + if (!apiKey || typeof apiKey !== 'string') { + sendJson(res, 400, { error: 'API key is required' }); + return; + } + + if (!apiKey.startsWith('sk-ant-')) { + sendJson(res, 400, { error: 'Invalid API key format' }); + return; + } + + // Check if user already has a session + const cookies = parseCookies(req); + const existingSessionId = cookies[COOKIE_NAME]; + + if (existingSessionId) { + const existingSession = db.getSession(existingSessionId); + if (existingSession) { + // Update existing user's API key + db.updateApiKey(existingSession.userId, apiKey); + sendJson(res, 200, { success: true, message: 'API key updated' }); + return; + } + } + + // Create new user and session + const user = db.createUser(apiKey); + const session = db.createSession(user.id, SESSION_TTL_MS); + + setSessionCookie(res, session.id); + sendJson(res, 200, { success: true, message: 'API key saved' }); + } catch (err) { + console.error('Setup error:', err); + sendJson(res, 500, { error: 'Internal server error' }); + } +} + +/** + * Proxy request to Anthropic API + */ +async function handleProxy(req: http.IncomingMessage, res: http.ServerResponse): Promise { + // Check session + const cookies = parseCookies(req); + const sessionId = cookies[COOKIE_NAME]; + + if (!sessionId) { + sendJson(res, 401, { error: 'Not authenticated. Please visit /setup first.' }); + return; + } + + const session = db.getSession(sessionId); + if (!session) { + clearSessionCookie(res); + sendJson(res, 401, { error: 'Session expired. Please visit /setup to re-authenticate.' }); + return; + } + + // Get API key + const apiKey = db.getApiKey(session.userId); + if (!apiKey) { + sendJson(res, 500, { error: 'API key not found' }); + return; + } + + try { + const body = await readBody(req); + + // Parse the request to get the target path + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const targetPath = url.searchParams.get('path') || '/v1/messages'; + + // Make request to Anthropic + const anthropicUrl = new URL(targetPath, ANTHROPIC_API_BASE); + + const proxyReq = https.request(anthropicUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01' + } + }, (proxyRes) => { + // Forward response headers (except sensitive ones) + const forwardHeaders: Record = {}; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (!['set-cookie', 'x-api-key'].includes(key.toLowerCase())) { + forwardHeaders[key] = value; + } + } + + res.writeHead(proxyRes.statusCode || 500, forwardHeaders); + proxyRes.pipe(res); + }); + + proxyReq.on('error', (err) => { + console.error('Proxy error:', err); + sendJson(res, 502, { error: 'Failed to reach AI provider' }); + }); + + proxyReq.write(body); + proxyReq.end(); + } catch (err) { + console.error('Proxy error:', err); + sendJson(res, 500, { error: 'Internal server error' }); + } +} + +/** + * Handle session status check + */ +function handleStatus(req: http.IncomingMessage, res: http.ServerResponse): void { + const cookies = parseCookies(req); + const sessionId = cookies[COOKIE_NAME]; + + if (!sessionId) { + sendJson(res, 200, { authenticated: false }); + return; + } + + const session = db.getSession(sessionId); + if (!session) { + clearSessionCookie(res); + sendJson(res, 200, { authenticated: false }); + return; + } + + sendJson(res, 200, { + authenticated: true, + expiresAt: session.expiresAt + }); +} + +/** + * Main request handler + */ +async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + // CORS headers for credential server + const origin = req.headers.origin; + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + } + + // Handle preflight + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const pathname = url.pathname; + + // Health check + if (pathname === '/health') { + sendJson(res, 200, { status: 'ok' }); + return; + } + + // Setup page + if (pathname === '/setup') { + if (req.method === 'GET') { + handleSetupPage(req, res); + } else if (req.method === 'POST') { + await handleSetupPost(req, res); + } else { + sendJson(res, 405, { error: 'Method not allowed' }); + } + return; + } + + // Status check + if (pathname === '/status') { + handleStatus(req, res); + return; + } + + // Proxy endpoint + if (pathname === '/proxy') { + if (req.method === 'POST') { + await handleProxy(req, res); + } else { + sendJson(res, 405, { error: 'Method not allowed' }); + } + return; + } + + // Not found + sendJson(res, 404, { error: 'Not found' }); +} + +/** + * Start the server + */ +function startServer(): void { + // Initialize database + const dbPath = process.env.DB_PATH || './credentials.db'; + db = new CredentialDatabase(dbPath); + + // Cleanup expired sessions periodically + setInterval(() => { + const cleaned = db.cleanupExpiredSessions(); + if (cleaned > 0) { + console.log(`Cleaned up ${cleaned} expired sessions`); + } + }, 60 * 60 * 1000); // Every hour + + const server = http.createServer((req, res) => { + handleRequest(req, res).catch(err => { + console.error('Unhandled error:', err); + sendJson(res, 500, { error: 'Internal server error' }); + }); + }); + + server.listen(PORT, '0.0.0.0', () => { + console.log(`Credential server running on port ${PORT}`); + console.log(`Setup page: http://localhost:${PORT}/setup`); + console.log(`Proxy endpoint: http://localhost:${PORT}/proxy`); + }); +} + +// Export for testing +export { CredentialDatabase, startServer }; + +// Start if run directly +if (require.main === module) { + startServer(); +} diff --git a/src/providers/ProxiedAnthropicProvider.ts b/src/providers/ProxiedAnthropicProvider.ts new file mode 100644 index 0000000..b056fee --- /dev/null +++ b/src/providers/ProxiedAnthropicProvider.ts @@ -0,0 +1,302 @@ +/** + * Proxied Anthropic Provider + * + * Routes all API requests through the credential server. + * Credentials are managed by HTTP-only cookies, never exposed to JavaScript. + */ + +import { Provider } from './Provider'; +import { Credentials, ProviderRequest, AiResponse, ModelMapping, ModelInfo } from '../types'; + +/** + * Configuration for the proxied provider + */ +export interface ProxiedProviderConfig { + /** + * Base URL of the credential server + * e.g., 'http://localhost:3001' or 'https://creds.example.com' + */ + credentialServerUrl: string; +} + +/** + * Anthropic provider that routes requests through a credential server + * for enhanced security. API keys never leave the server. + */ +export class ProxiedAnthropicProvider extends Provider { + readonly name = 'anthropic' as const; + private _availableModels: string[] | null = null; + readonly defaultModels: ModelMapping | null = null; + private serverUrl: string; + + constructor(config: ProxiedProviderConfig) { + super(); + this.serverUrl = config.credentialServerUrl.replace(/\/$/, ''); // Remove trailing slash + } + + get availableModels(): string[] | null { + return this._availableModels; + } + + /** + * Check if the user has an authenticated session with the credential server + */ + async checkAuthStatus(): Promise<{ authenticated: boolean; expiresAt?: number }> { + const response = await fetch(`${this.serverUrl}/status`, { + method: 'GET', + credentials: 'include' + }); + + return response.json(); + } + + /** + * Get the URL for the setup page + */ + getSetupUrl(): string { + return `${this.serverUrl}/setup`; + } + + /** + * Fetch available models via the proxy + * Note: Currently returns a static list since the proxy endpoint + * would need to be extended to support model listing + */ + async fetchAvailableModels(_credentials: Credentials): Promise { + // For now, return commonly available models + // In future, this could be proxied through the credential server + const staticModels: ModelInfo[] = [ + { id: 'claude-sonnet-4-20250514', display_name: 'Claude Sonnet 4', created_at: '2025-05-14T00:00:00Z', type: 'model' }, + { id: 'claude-3-5-sonnet-20241022', display_name: 'Claude 3.5 Sonnet', created_at: '2024-10-22T00:00:00Z', type: 'model' }, + { id: 'claude-3-5-haiku-20241022', display_name: 'Claude 3.5 Haiku', created_at: '2024-10-22T00:00:00Z', type: 'model' }, + { id: 'claude-3-opus-20240229', display_name: 'Claude 3 Opus', created_at: '2024-02-29T00:00:00Z', type: 'model' } + ]; + + this._availableModels = staticModels.map(m => m.id); + return staticModels; + } + + /** + * Get the latest model from a specific family + */ + getLatestModelByFamily(models: ModelInfo[], family: 'haiku' | 'sonnet' | 'opus'): string | undefined { + const familyModels = models.filter(m => + m.id.toLowerCase().includes(family.toLowerCase()) + ); + + if (familyModels.length === 0) { + return undefined; + } + + familyModels.sort((a, b) => { + const dateA = new Date(a.created_at).getTime(); + const dateB = new Date(b.created_at).getTime(); + return dateB - dateA; + }); + + return familyModels[0].id; + } + + /** + * Validate credentials by checking session status + * In proxied mode, we just check if there's a valid session + */ + async validateCredentials(_credentials: Credentials): Promise { + const status = await this.checkAuthStatus(); + return status.authenticated; + } + + /** + * Make a request through the credential server proxy + */ + async makeRequest( + request: ProviderRequest, + _credentials: Credentials, // Ignored - server uses session cookie + onStream?: (chunk: string) => void + ): Promise { + this.validateRequest(request); + + // Check authentication first + const status = await this.checkAuthStatus(); + if (!status.authenticated) { + throw new Error(`Not authenticated. Please visit ${this.getSetupUrl()} to set up your API key.`); + } + + try { + if (request.stream && onStream) { + return await this.handleStreamingRequest(request, onStream); + } + return await this.handleNonStreamingRequest(request); + } catch (error: any) { + throw this.handleError(error); + } + } + + /** + * Handle non-streaming request through proxy + */ + private async handleNonStreamingRequest(request: ProviderRequest): Promise { + const anthropicRequest = { + model: request.model, + max_tokens: request.max_tokens ?? 4096, + temperature: request.temperature, + system: request.system, + messages: request.messages + .filter(msg => msg.role !== 'system') + .map(msg => ({ + role: msg.role as 'user' | 'assistant', + content: msg.content + })) + }; + + const response = await fetch(`${this.serverUrl}/proxy?path=/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', // Include session cookie + body: JSON.stringify(anthropicRequest) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(errorData.error?.message || errorData.error || `HTTP ${response.status}`); + } + + const data = await response.json(); + + // Extract text from content blocks + const textBlock = data.content?.find((block: any) => block.type === 'text'); + const content = textBlock && 'text' in textBlock ? textBlock.text : ''; + + return { + content, + model: data.model, + provider: 'anthropic', + usage: { + input_tokens: data.usage?.input_tokens || 0, + output_tokens: data.usage?.output_tokens || 0 + }, + stop_reason: data.stop_reason ?? undefined + }; + } + + /** + * Handle streaming request through proxy + * Note: Streaming requires special handling in the proxy + */ + private async handleStreamingRequest( + request: ProviderRequest, + onStream: (chunk: string) => void + ): Promise { + const anthropicRequest = { + model: request.model, + max_tokens: request.max_tokens ?? 4096, + temperature: request.temperature, + system: request.system, + messages: request.messages + .filter(msg => msg.role !== 'system') + .map(msg => ({ + role: msg.role as 'user' | 'assistant', + content: msg.content + })), + stream: true + }; + + const response = await fetch(`${this.serverUrl}/proxy?path=/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify(anthropicRequest) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(errorData.error?.message || errorData.error || `HTTP ${response.status}`); + } + + // Process SSE stream + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('Streaming not supported'); + } + + const decoder = new TextDecoder(); + let fullContent = ''; + let model = request.model; + let inputTokens = 0; + let outputTokens = 0; + let stopReason: string | undefined; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') continue; + + try { + const event = JSON.parse(data); + + if (event.type === 'message_start') { + model = event.message.model; + inputTokens = event.message.usage?.input_tokens || 0; + } else if (event.type === 'content_block_delta') { + if (event.delta?.type === 'text_delta') { + const text = event.delta.text; + fullContent += text; + onStream(text); + } + } else if (event.type === 'message_delta') { + outputTokens = event.usage?.output_tokens || 0; + stopReason = event.delta?.stop_reason ?? undefined; + } + } catch { + // Ignore parse errors for incomplete chunks + } + } + } + } + } finally { + reader.releaseLock(); + } + + return { + content: fullContent, + model, + provider: 'anthropic', + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens + }, + stop_reason: stopReason + }; + } + + /** + * Handle and transform errors + */ + private handleError(error: any): Error { + const message = error?.message || 'Unknown error'; + + if (message.includes('401') || message.includes('authentication')) { + return new Error(`Not authenticated. Please visit ${this.getSetupUrl()} to set up your API key.`); + } + if (message.includes('429')) { + return new Error('Rate limit exceeded. Please try again later.'); + } + if (message.includes('400')) { + return new Error(`Bad request: ${message}`); + } + + return new Error(`API request failed: ${message}`); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index c4fce0d..64e1b45 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -109,6 +109,14 @@ export interface AiDelegateInitOptions { * Example: '/ai-delegate' */ endpoint: string; + + /** + * Optional URL of the credential server. + * When provided, requests are proxied through the credential server + * and API keys are stored securely on the server instead of in the browser. + * Example: 'https://creds.example.com' + */ + credentialServerUrl?: string; } /** diff --git a/src/worker/WorkerWrapper.ts b/src/worker/WorkerWrapper.ts index a6c27c3..eed446d 100644 --- a/src/worker/WorkerWrapper.ts +++ b/src/worker/WorkerWrapper.ts @@ -12,7 +12,8 @@ import { ConfigurePayload, MakeRequestPayload, UnlockPayload, - UpdateCredentialsPayload + UpdateCredentialsPayload, + InitPayload } from './types'; import { MessageProtocol } from './MessageProtocol'; @@ -30,8 +31,10 @@ export class WorkerWrapper { /** * Initialize the worker + * @param workerUrl - URL to the worker script + * @param credentialServerUrl - Optional URL of the credential server for proxied mode */ - async initialize(workerUrl: string): Promise { + async initialize(workerUrl: string, credentialServerUrl?: string): Promise { if (this.worker) { return; // Already initialized } @@ -50,6 +53,10 @@ export class WorkerWrapper { }); this.pendingRequests.clear(); }); + + // Send INIT command with optional credential server URL + const payload: InitPayload = { credentialServerUrl }; + await this.sendCommand(WorkerCommand.INIT, payload); } /** diff --git a/src/worker/types.ts b/src/worker/types.ts index b57496d..cb6519b 100644 --- a/src/worker/types.ts +++ b/src/worker/types.ts @@ -10,6 +10,7 @@ import { * Commands that can be sent to the worker */ export enum WorkerCommand { + INIT = 'init', CONFIGURE = 'configure', MAKE_REQUEST = 'makeRequest', GET_STATUS = 'getStatus', @@ -45,6 +46,10 @@ export interface WorkerResponse { /** * Specific command payloads */ +export interface InitPayload { + credentialServerUrl?: string; +} + export interface ConfigurePayload { config: AiDelegateConfig; password: string; @@ -67,6 +72,7 @@ export interface UpdateCredentialsPayload { * Typed worker messages */ export type TypedWorkerMessage = + | { id: string; command: WorkerCommand.INIT; payload: InitPayload } | { id: string; command: WorkerCommand.CONFIGURE; payload: ConfigurePayload } | { id: string; command: WorkerCommand.MAKE_REQUEST; payload: MakeRequestPayload } | { id: string; command: WorkerCommand.GET_STATUS; payload?: undefined } diff --git a/src/worker/worker.ts b/src/worker/worker.ts index 757f99f..8df2c34 100644 --- a/src/worker/worker.ts +++ b/src/worker/worker.ts @@ -11,10 +11,39 @@ import { IndexedDBAdapter } from '../storage/IndexedDBAdapter'; * Credentials and sensitive data NEVER leave this context */ -// Initialize storage and config manager for worker context +// Storage and config manager (initialized immediately) const storage = new IndexedDBAdapter(); const configManager = new ConfigManager(storage); -const delegate = AiDelegateCore.getInstance(configManager); + +// Delegate is initialized after INIT command with optional credentialServerUrl +let delegate: AiDelegateCore | null = null; +let isInitialized = false; + +/** + * Initialize the delegate with optional credential server URL + */ +function initializeDelegate(credentialServerUrl?: string): void { + if (isInitialized) return; + + delegate = AiDelegateCore.getInstance(configManager, credentialServerUrl); + + // Inject prompt handler + delegate.setPromptHandler(workerPromptHandler); + + isInitialized = true; + console.log('[AI Delegate Worker] Initialized', credentialServerUrl ? `with credential server: ${credentialServerUrl}` : 'in direct mode'); +} + +/** + * Get the delegate, initializing with default settings if not yet initialized + */ +function getDelegate(): AiDelegateCore { + if (!delegate) { + // Initialize with default settings (no credential server) + initializeDelegate(); + } + return delegate!; +} // Track pending prompt requests const pendingPrompts = new Map Date: Fri, 12 Dec 2025 04:59:18 -0500 Subject: [PATCH 2/7] fix(docker): use --ignore-scripts in credential-server Dockerfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docker/credential-server/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/credential-server/Dockerfile b/docker/credential-server/Dockerfile index 2bf48b3..88d21b7 100644 --- a/docker/credential-server/Dockerfile +++ b/docker/credential-server/Dockerfile @@ -7,7 +7,7 @@ RUN apk add --no-cache python3 make g++ # Copy source and install dependencies COPY package*.json ./ -RUN npm ci +RUN npm ci --ignore-scripts COPY . . RUN npm run build -- GitLab From 7339d449f321568b9fa55b63648457ffb497e395 Mon Sep 17 00:00:00 2001 From: Lembot Date: Fri, 12 Dec 2025 20:46:47 -0500 Subject: [PATCH 3/7] fix: use per-user encryption for credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: each user's credentials are now encrypted with a unique key derived from: - Server's master secret (env var) - Per-user salt (stored in DB) - Session key part (stored in cookie only, NOT in DB) This ensures: - Database leak alone cannot decrypt any credentials - Stolen session alone cannot decrypt credentials - Credentials are tied to specific sessions Changes: - Add keySalt field to users table - Add keyPart to Session (stored in cookie, not DB) - deriveUserKey() combines all three components via PBKDF2 - Session cookie now contains base64(sessionId:keyPart) - All encrypt/decrypt operations use user-specific keys 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/credential-server/db.ts | 104 ++++++++++++++++++++++----------- src/credential-server/index.ts | 85 +++++++++++++++++++-------- 2 files changed, 132 insertions(+), 57 deletions(-) diff --git a/src/credential-server/db.ts b/src/credential-server/db.ts index 4332abe..8f8a07f 100644 --- a/src/credential-server/db.ts +++ b/src/credential-server/db.ts @@ -16,6 +16,7 @@ const ITERATIONS = 100000; export interface User { id: string; encryptedApiKey: string; + keySalt: string; // Per-user salt for key derivation createdAt: number; updatedAt: number; } @@ -23,13 +24,14 @@ export interface User { export interface Session { id: string; userId: string; + keyPart: string; // Part of encryption key, stored in cookie only (not in DB) createdAt: number; expiresAt: number; } export class CredentialDatabase { private db: Database.Database; - private encryptionKey: Buffer; + private masterSecret: string; constructor(dbPath?: string, encryptionSecret?: string) { const resolvedPath = dbPath || path.join(process.cwd(), 'credentials.db'); @@ -39,17 +41,24 @@ export class CredentialDatabase { throw new Error('CREDENTIAL_ENCRYPTION_SECRET environment variable is required'); } - // Derive encryption key from secret - this.encryptionKey = crypto.pbkdf2Sync( - secret, - 'ai-delegate-salt', // Static salt for key derivation + this.masterSecret = secret; + this.db = new Database(resolvedPath); + this.initSchema(); + } + + /** + * Derive a per-user encryption key from master secret, user salt, and session key part + */ + private deriveUserKey(userSalt: string, sessionKeyPart: string): Buffer { + // Combine master secret with per-user salt and session-specific key part + const combined = `${this.masterSecret}:${userSalt}:${sessionKeyPart}`; + return crypto.pbkdf2Sync( + combined, + userSalt, ITERATIONS, KEY_LENGTH, 'sha256' ); - - this.db = new Database(resolvedPath); - this.initSchema(); } private initSchema(): void { @@ -57,6 +66,7 @@ export class CredentialDatabase { CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, encrypted_api_key TEXT NOT NULL, + key_salt TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -75,11 +85,11 @@ export class CredentialDatabase { } /** - * Encrypt a value using AES-256-GCM + * Encrypt a value using AES-256-GCM with the provided key */ - private encrypt(plaintext: string): string { + private encrypt(plaintext: string, key: Buffer): string { const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, this.encryptionKey, iv); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); const encrypted = Buffer.concat([ cipher.update(plaintext, 'utf8'), @@ -94,16 +104,16 @@ export class CredentialDatabase { } /** - * Decrypt a value encrypted with AES-256-GCM + * Decrypt a value encrypted with AES-256-GCM using the provided key */ - private decrypt(ciphertext: string): string { + private decrypt(ciphertext: string, key: Buffer): string { const data = Buffer.from(ciphertext, 'base64'); const iv = data.subarray(0, IV_LENGTH); const authTag = data.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); const encrypted = data.subarray(IV_LENGTH + AUTH_TAG_LENGTH); - const decipher = crypto.createDecipheriv(ALGORITHM, this.encryptionKey, iv); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); const decrypted = Buffer.concat([ @@ -116,46 +126,70 @@ export class CredentialDatabase { /** * Create a new user with an encrypted API key + * Returns the user and a session key part that must be stored in the session cookie */ - createUser(apiKey: string): User { + createUser(apiKey: string): { user: User; sessionKeyPart: string } { const id = crypto.randomUUID(); const now = Date.now(); - const encryptedApiKey = this.encrypt(apiKey); + + // Generate per-user salt and session key part + const keySalt = crypto.randomBytes(32).toString('hex'); + const sessionKeyPart = crypto.randomBytes(32).toString('hex'); + + // Derive user-specific encryption key + const userKey = this.deriveUserKey(keySalt, sessionKeyPart); + const encryptedApiKey = this.encrypt(apiKey, userKey); const stmt = this.db.prepare(` - INSERT INTO users (id, encrypted_api_key, created_at, updated_at) - VALUES (?, ?, ?, ?) + INSERT INTO users (id, encrypted_api_key, key_salt, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) `); - stmt.run(id, encryptedApiKey, now, now); + stmt.run(id, encryptedApiKey, keySalt, now, now); return { - id, - encryptedApiKey, - createdAt: now, - updatedAt: now + user: { + id, + encryptedApiKey, + keySalt, + createdAt: now, + updatedAt: now + }, + sessionKeyPart }; } /** - * Get decrypted API key for a user + * Get decrypted API key for a user using their session key part */ - getApiKey(userId: string): string | null { - const stmt = this.db.prepare('SELECT encrypted_api_key FROM users WHERE id = ?'); - const row = stmt.get(userId) as { encrypted_api_key: string } | undefined; + getApiKey(userId: string, sessionKeyPart: string): string | null { + const stmt = this.db.prepare('SELECT encrypted_api_key, key_salt FROM users WHERE id = ?'); + const row = stmt.get(userId) as { encrypted_api_key: string; key_salt: string } | undefined; if (!row) { return null; } - return this.decrypt(row.encrypted_api_key); + // Derive the user's encryption key using stored salt and session key part + const userKey = this.deriveUserKey(row.key_salt, sessionKeyPart); + return this.decrypt(row.encrypted_api_key, userKey); } /** - * Update a user's API key + * Update a user's API key using their session key part */ - updateApiKey(userId: string, newApiKey: string): boolean { - const encryptedApiKey = this.encrypt(newApiKey); + updateApiKey(userId: string, newApiKey: string, sessionKeyPart: string): boolean { + // Get the user's salt + const userStmt = this.db.prepare('SELECT key_salt FROM users WHERE id = ?'); + const userRow = userStmt.get(userId) as { key_salt: string } | undefined; + + if (!userRow) { + return false; + } + + // Derive key and encrypt + const userKey = this.deriveUserKey(userRow.key_salt, sessionKeyPart); + const encryptedApiKey = this.encrypt(newApiKey, userKey); const now = Date.now(); const stmt = this.db.prepare(` @@ -168,8 +202,9 @@ export class CredentialDatabase { /** * Create a new session for a user + * The keyPart is stored in the session cookie, not in the database */ - createSession(userId: string, ttlMs: number = 7 * 24 * 60 * 60 * 1000): Session { + createSession(userId: string, keyPart: string, ttlMs: number = 7 * 24 * 60 * 60 * 1000): Session { const id = crypto.randomBytes(32).toString('hex'); const now = Date.now(); const expiresAt = now + ttlMs; @@ -184,6 +219,7 @@ export class CredentialDatabase { return { id, userId, + keyPart, // Returned but NOT stored in DB - must be stored in cookie createdAt: now, expiresAt }; @@ -191,8 +227,9 @@ export class CredentialDatabase { /** * Get session by ID (returns null if expired or not found) + * The keyPart must be provided from the cookie since it's not stored in DB */ - getSession(sessionId: string): Session | null { + getSession(sessionId: string, keyPart: string): Session | null { const stmt = this.db.prepare(` SELECT id, user_id, created_at, expires_at FROM sessions WHERE id = ? AND expires_at > ? @@ -212,6 +249,7 @@ export class CredentialDatabase { return { id: row.id, userId: row.user_id, + keyPart, // From cookie, not from DB createdAt: row.created_at, expiresAt: row.expires_at }; diff --git a/src/credential-server/index.ts b/src/credential-server/index.ts index ed56ba1..f11b0c4 100644 --- a/src/credential-server/index.ts +++ b/src/credential-server/index.ts @@ -37,12 +37,15 @@ function parseCookies(req: http.IncomingMessage): Record { } /** - * Set HTTP-only secure cookie + * Set HTTP-only secure cookie with session ID and key part + * Format: sessionId.keyPart (base64 encoded) */ -function setSessionCookie(res: http.ServerResponse, sessionId: string): void { +function setSessionCookie(res: http.ServerResponse, sessionId: string, keyPart: string): void { const isProduction = process.env.NODE_ENV === 'production'; + // Combine sessionId and keyPart with a delimiter, then base64 encode + const cookieValue = Buffer.from(`${sessionId}:${keyPart}`).toString('base64'); const cookieOptions = [ - `${COOKIE_NAME}=${sessionId}`, + `${COOKIE_NAME}=${cookieValue}`, 'HttpOnly', 'Path=/', `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, @@ -56,6 +59,22 @@ function setSessionCookie(res: http.ServerResponse, sessionId: string): void { res.setHeader('Set-Cookie', cookieOptions.join('; ')); } +/** + * Parse session cookie to extract sessionId and keyPart + */ +function parseSessionCookie(cookieValue: string): { sessionId: string; keyPart: string } | null { + try { + const decoded = Buffer.from(cookieValue, 'base64').toString('utf8'); + const [sessionId, keyPart] = decoded.split(':'); + if (sessionId && keyPart) { + return { sessionId, keyPart }; + } + return null; + } catch { + return null; + } +} + /** * Clear session cookie */ @@ -220,23 +239,27 @@ async function handleSetupPost(req: http.IncomingMessage, res: http.ServerRespon // Check if user already has a session const cookies = parseCookies(req); - const existingSessionId = cookies[COOKIE_NAME]; - - if (existingSessionId) { - const existingSession = db.getSession(existingSessionId); - if (existingSession) { - // Update existing user's API key - db.updateApiKey(existingSession.userId, apiKey); - sendJson(res, 200, { success: true, message: 'API key updated' }); - return; + const existingCookie = cookies[COOKIE_NAME]; + + if (existingCookie) { + const parsed = parseSessionCookie(existingCookie); + if (parsed) { + const existingSession = db.getSession(parsed.sessionId, parsed.keyPart); + if (existingSession) { + // Update existing user's API key using their session key part + db.updateApiKey(existingSession.userId, apiKey, existingSession.keyPart); + sendJson(res, 200, { success: true, message: 'API key updated' }); + return; + } } } // Create new user and session - const user = db.createUser(apiKey); - const session = db.createSession(user.id, SESSION_TTL_MS); + // createUser returns user and sessionKeyPart (the key part for encryption) + const { user, sessionKeyPart } = db.createUser(apiKey); + const session = db.createSession(user.id, sessionKeyPart, SESSION_TTL_MS); - setSessionCookie(res, session.id); + setSessionCookie(res, session.id, session.keyPart); sendJson(res, 200, { success: true, message: 'API key saved' }); } catch (err) { console.error('Setup error:', err); @@ -250,24 +273,31 @@ async function handleSetupPost(req: http.IncomingMessage, res: http.ServerRespon async function handleProxy(req: http.IncomingMessage, res: http.ServerResponse): Promise { // Check session const cookies = parseCookies(req); - const sessionId = cookies[COOKIE_NAME]; + const sessionCookie = cookies[COOKIE_NAME]; - if (!sessionId) { + if (!sessionCookie) { sendJson(res, 401, { error: 'Not authenticated. Please visit /setup first.' }); return; } - const session = db.getSession(sessionId); + const parsed = parseSessionCookie(sessionCookie); + if (!parsed) { + clearSessionCookie(res); + sendJson(res, 401, { error: 'Invalid session. Please visit /setup to re-authenticate.' }); + return; + } + + const session = db.getSession(parsed.sessionId, parsed.keyPart); if (!session) { clearSessionCookie(res); sendJson(res, 401, { error: 'Session expired. Please visit /setup to re-authenticate.' }); return; } - // Get API key - const apiKey = db.getApiKey(session.userId); + // Get API key using session's key part for decryption + const apiKey = db.getApiKey(session.userId, session.keyPart); if (!apiKey) { - sendJson(res, 500, { error: 'API key not found' }); + sendJson(res, 500, { error: 'API key not found or decryption failed' }); return; } @@ -319,14 +349,21 @@ async function handleProxy(req: http.IncomingMessage, res: http.ServerResponse): */ function handleStatus(req: http.IncomingMessage, res: http.ServerResponse): void { const cookies = parseCookies(req); - const sessionId = cookies[COOKIE_NAME]; + const sessionCookie = cookies[COOKIE_NAME]; + + if (!sessionCookie) { + sendJson(res, 200, { authenticated: false }); + return; + } - if (!sessionId) { + const parsed = parseSessionCookie(sessionCookie); + if (!parsed) { + clearSessionCookie(res); sendJson(res, 200, { authenticated: false }); return; } - const session = db.getSession(sessionId); + const session = db.getSession(parsed.sessionId, parsed.keyPart); if (!session) { clearSessionCookie(res); sendJson(res, 200, { authenticated: false }); -- GitLab From e77dcba9cb695dea2a7755890ac8421472519e5b Mon Sep 17 00:00:00 2001 From: Lembot Date: Fri, 12 Dec 2025 21:02:18 -0500 Subject: [PATCH 4/7] refactor: combine credential server with ai-delegate server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged credential server functionality into the main ai-delegate server: - Server now handles both worker file serving and credential management - Credentials enabled when CREDENTIAL_ENCRYPTION_SECRET env var is set - Updated Dockerfile with SQLite build deps and /data volume - Single container deployment instead of separate credential server 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docker/ai-delegate/Dockerfile | 13 +- docker/ai-delegate/server.js | 25 +- src/server/index.ts | 501 ++++++++++++++++++++++++++++++---- 3 files changed, 470 insertions(+), 69 deletions(-) diff --git a/docker/ai-delegate/Dockerfile b/docker/ai-delegate/Dockerfile index ec885b6..0acc0ff 100644 --- a/docker/ai-delegate/Dockerfile +++ b/docker/ai-delegate/Dockerfile @@ -2,6 +2,9 @@ FROM node:24-alpine AS base WORKDIR /app +# Install build dependencies for native modules (better-sqlite3) +RUN apk add --no-cache python3 make g++ + # Copy source and install dependencies COPY package*.json ./ RUN npm ci --ignore-scripts @@ -16,11 +19,19 @@ WORKDIR /app # Copy only what's needed for production COPY --from=base /app/package*.json ./ +COPY --from=base /app/node_modules ./node_modules COPY --from=base /app/dist ./dist COPY --from=base /app/docker/ai-delegate/server.js ./server.js -# Default port +# Create data directory for credentials database +RUN mkdir -p /data + +# Default configuration ENV PORT=3000 +ENV DB_PATH=/data/credentials.db + +# Volume for persistent credential storage +VOLUME /data EXPOSE 3000 diff --git a/docker/ai-delegate/server.js b/docker/ai-delegate/server.js index 5012ac5..519f0bc 100644 --- a/docker/ai-delegate/server.js +++ b/docker/ai-delegate/server.js @@ -2,7 +2,10 @@ /** * AI Delegate Standalone Server * - * A minimal HTTP server that serves the AI Delegate worker script. + * A combined HTTP server that: + * 1. Serves the AI Delegate worker script + * 2. Handles credential storage and API proxying (when enabled) + * * Designed for Docker deployment. */ @@ -10,17 +13,14 @@ const http = require('http'); const { createRequestHandler } = require('@peerverity/ai-delegate/server'); const PORT = process.env.PORT || 3000; -const handler = createRequestHandler(); +const credentialsEnabled = !!process.env.CREDENTIAL_ENCRYPTION_SECRET; -const server = http.createServer(async (req, res) => { - // Health check - if (req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end('{"status":"ok"}'); - return; - } +const handler = createRequestHandler({ + enableCredentials: credentialsEnabled, + dbPath: process.env.DB_PATH || './credentials.db', +}); - // Try to handle with ai-delegate +const server = http.createServer(async (req, res) => { const handled = await handler(req, res); if (!handled) { @@ -32,4 +32,9 @@ const server = http.createServer(async (req, res) => { server.listen(PORT, '0.0.0.0', () => { console.log(`AI Delegate server running on port ${PORT}`); console.log(`Worker available at: http://0.0.0.0:${PORT}/ai-delegate-worker.js`); + if (credentialsEnabled) { + console.log(`Credential server enabled:`); + console.log(` Setup page: http://0.0.0.0:${PORT}/setup`); + console.log(` Proxy endpoint: http://0.0.0.0:${PORT}/proxy`); + } }); diff --git a/src/server/index.ts b/src/server/index.ts index 0dcb0cf..18c24ff 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,8 +1,9 @@ /** * AI Delegate Server * - * A self-contained server/middleware for serving AI Delegate assets. - * Currently serves the service worker code, with support for future expansion. + * A combined server that: + * 1. Serves the AI Delegate web worker script + * 2. Handles credential storage and API proxying * * Can be used as: * 1. Express/Connect middleware @@ -11,8 +12,10 @@ */ import { IncomingMessage, ServerResponse } from 'http'; +import * as https from 'https'; import * as fs from 'fs'; import * as path from 'path'; +import { CredentialDatabase } from '../credential-server/db'; export interface AiDelegateServerOptions { /** @@ -20,14 +23,32 @@ export interface AiDelegateServerOptions { * Defaults to the package's dist directory. */ distPath?: string; + + /** + * Enable credential server functionality. + * Requires CREDENTIAL_ENCRYPTION_SECRET environment variable. + */ + enableCredentials?: boolean; + + /** + * Path to SQLite database file for credentials. + * Defaults to './credentials.db' + */ + dbPath?: string; } +// Constants +const COOKIE_NAME = 'aid_session'; +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const ANTHROPIC_API_BASE = 'https://api.anthropic.com'; + /** * MIME types for served files */ const MIME_TYPES: Record = { '.js': 'application/javascript', '.map': 'application/json', + '.html': 'text/html', }; /** @@ -39,67 +60,447 @@ const ALLOWED_FILES = [ ]; /** - * Creates a request handler for serving AI Delegate assets. - * This can be used directly or wrapped by middleware adapters. + * Parse cookies from request headers + */ +function parseCookies(req: IncomingMessage): Record { + const cookies: Record = {}; + const cookieHeader = req.headers.cookie; + + if (cookieHeader) { + cookieHeader.split(';').forEach(cookie => { + const [name, ...rest] = cookie.trim().split('='); + cookies[name] = rest.join('='); + }); + } + + return cookies; +} + +/** + * Parse session cookie to extract sessionId and keyPart + */ +function parseSessionCookie(cookieValue: string): { sessionId: string; keyPart: string } | null { + try { + const decoded = Buffer.from(cookieValue, 'base64').toString('utf8'); + const [sessionId, keyPart] = decoded.split(':'); + if (sessionId && keyPart) { + return { sessionId, keyPart }; + } + return null; + } catch { + return null; + } +} + +/** + * Set HTTP-only secure cookie with session ID and key part + */ +function setSessionCookie(res: ServerResponse, sessionId: string, keyPart: string): void { + const isProduction = process.env.NODE_ENV === 'production'; + const cookieValue = Buffer.from(`${sessionId}:${keyPart}`).toString('base64'); + const cookieOptions = [ + `${COOKIE_NAME}=${cookieValue}`, + 'HttpOnly', + 'Path=/', + `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, + 'SameSite=Strict' + ]; + + if (isProduction) { + cookieOptions.push('Secure'); + } + + res.setHeader('Set-Cookie', cookieOptions.join('; ')); +} + +/** + * Clear session cookie + */ +function clearSessionCookie(res: ServerResponse): void { + res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Strict`); +} + +/** + * Send JSON response + */ +function sendJson(res: ServerResponse, statusCode: number, data: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +} + +/** + * Read request body as string + */ +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +/** + * Setup page HTML + */ +const SETUP_PAGE_HTML = ` + + + + + AI Delegate - Setup + + + +
+

AI Delegate Setup

+

Enter your Anthropic API key to get started. Your key will be encrypted and stored securely on this server.

+
+ + + +
+

+

+
+ + +`; + +/** + * Creates a combined request handler for AI Delegate server. */ export function createHandler(options: AiDelegateServerOptions = {}) { const distPath = options.distPath || getDefaultDistPath(); + const enableCredentials = options.enableCredentials ?? !!process.env.CREDENTIAL_ENCRYPTION_SECRET; + + let db: CredentialDatabase | null = null; + + if (enableCredentials) { + const dbPath = options.dbPath || process.env.DB_PATH || './credentials.db'; + db = new CredentialDatabase(dbPath); + + // Cleanup expired sessions periodically + setInterval(() => { + const cleaned = db!.cleanupExpiredSessions(); + if (cleaned > 0) { + console.log(`[AiDelegate] Cleaned up ${cleaned} expired sessions`); + } + }, 60 * 60 * 1000); // Every hour + } return async function handleRequest( req: IncomingMessage, - res: ServerResponse, - useOriginalUrl: boolean = false + res: ServerResponse ): Promise { - // When used as Express middleware, req.url is already relative to mount point - // When used standalone, we need the full URL - const url = useOriginalUrl ? ((req as any).originalUrl || req.url || '/') : (req.url || '/'); + const url = req.url || '/'; const method = req.method || 'GET'; + const pathname = url.split('?')[0]; + + // CORS headers + const origin = req.headers.origin; + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + } + + // Handle preflight + if (method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return true; + } - // Only handle GET requests - if (method !== 'GET') { - return false; + // Health check + if (pathname === '/health') { + sendJson(res, 200, { status: 'ok', credentials: enableCredentials }); + return true; } - // Parse the URL path (remove query string) - const urlPath = url.split('?')[0]; + // Credential endpoints (if enabled) + if (enableCredentials && db) { + // Setup page + if (pathname === '/setup') { + if (method === 'GET') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(SETUP_PAGE_HTML); + return true; + } else if (method === 'POST') { + await handleSetupPost(req, res, db); + return true; + } + } + + // Status check + if (pathname === '/status' && method === 'GET') { + handleStatus(req, res, db); + return true; + } - // Normalize: remove leading slash - let relativePath = urlPath; - if (relativePath.startsWith('/')) { - relativePath = relativePath.slice(1); + // Proxy endpoint + if (pathname === '/proxy' && method === 'POST') { + await handleProxy(req, res, db); + return true; + } } - // Check if this is a request for an allowed file - if (!ALLOWED_FILES.includes(relativePath)) { - return false; + // Serve static worker files (GET only) + if (method === 'GET') { + let relativePath = pathname; + if (relativePath.startsWith('/')) { + relativePath = relativePath.slice(1); + } + + if (ALLOWED_FILES.includes(relativePath)) { + const filePath = path.join(distPath, relativePath); + + try { + await fs.promises.access(filePath, fs.constants.R_OK); + const stat = await fs.promises.stat(filePath); + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Length', stat.size); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + + const stream = fs.createReadStream(filePath); + res.statusCode = 200; + stream.pipe(res); + return true; + } catch { + // File not found + } + } } - const filePath = path.join(distPath, relativePath); + return false; + }; + async function handleSetupPost(req: IncomingMessage, res: ServerResponse, db: CredentialDatabase): Promise { try { - // Check if file exists and is readable - await fs.promises.access(filePath, fs.constants.R_OK); + const body = await readBody(req); + const { apiKey } = JSON.parse(body); - const stat = await fs.promises.stat(filePath); - const ext = path.extname(filePath); - const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + if (!apiKey || typeof apiKey !== 'string') { + sendJson(res, 400, { error: 'API key is required' }); + return; + } - // Set headers - res.setHeader('Content-Type', contentType); - res.setHeader('Content-Length', stat.size); - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + if (!apiKey.startsWith('sk-ant-')) { + sendJson(res, 400, { error: 'Invalid API key format' }); + return; + } - // Stream the file - const stream = fs.createReadStream(filePath); - res.statusCode = 200; - stream.pipe(res); + const cookies = parseCookies(req); + const existingCookie = cookies[COOKIE_NAME]; - return true; + if (existingCookie) { + const parsed = parseSessionCookie(existingCookie); + if (parsed) { + const existingSession = db.getSession(parsed.sessionId, parsed.keyPart); + if (existingSession) { + db.updateApiKey(existingSession.userId, apiKey, existingSession.keyPart); + sendJson(res, 200, { success: true, message: 'API key updated' }); + return; + } + } + } + + const { user, sessionKeyPart } = db.createUser(apiKey); + const session = db.createSession(user.id, sessionKeyPart, SESSION_TTL_MS); + + setSessionCookie(res, session.id, session.keyPart); + sendJson(res, 200, { success: true, message: 'API key saved' }); } catch (err) { - // File not found or not readable - return false; + console.error('[AiDelegate] Setup error:', err); + sendJson(res, 500, { error: 'Internal server error' }); } - }; + } + + function handleStatus(req: IncomingMessage, res: ServerResponse, db: CredentialDatabase): void { + const cookies = parseCookies(req); + const sessionCookie = cookies[COOKIE_NAME]; + + if (!sessionCookie) { + sendJson(res, 200, { authenticated: false }); + return; + } + + const parsed = parseSessionCookie(sessionCookie); + if (!parsed) { + clearSessionCookie(res); + sendJson(res, 200, { authenticated: false }); + return; + } + + const session = db.getSession(parsed.sessionId, parsed.keyPart); + if (!session) { + clearSessionCookie(res); + sendJson(res, 200, { authenticated: false }); + return; + } + + sendJson(res, 200, { + authenticated: true, + expiresAt: session.expiresAt + }); + } + + async function handleProxy(req: IncomingMessage, res: ServerResponse, db: CredentialDatabase): Promise { + const cookies = parseCookies(req); + const sessionCookie = cookies[COOKIE_NAME]; + + if (!sessionCookie) { + sendJson(res, 401, { error: 'Not authenticated. Please visit /setup first.' }); + return; + } + + const parsed = parseSessionCookie(sessionCookie); + if (!parsed) { + clearSessionCookie(res); + sendJson(res, 401, { error: 'Invalid session. Please visit /setup to re-authenticate.' }); + return; + } + + const session = db.getSession(parsed.sessionId, parsed.keyPart); + if (!session) { + clearSessionCookie(res); + sendJson(res, 401, { error: 'Session expired. Please visit /setup to re-authenticate.' }); + return; + } + + const apiKey = db.getApiKey(session.userId, session.keyPart); + if (!apiKey) { + sendJson(res, 500, { error: 'API key not found or decryption failed' }); + return; + } + + try { + const body = await readBody(req); + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const targetPath = url.searchParams.get('path') || '/v1/messages'; + + const anthropicUrl = new URL(targetPath, ANTHROPIC_API_BASE); + + const proxyReq = https.request(anthropicUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01' + } + }, (proxyRes) => { + const forwardHeaders: Record = {}; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (!['set-cookie', 'x-api-key'].includes(key.toLowerCase())) { + forwardHeaders[key] = value; + } + } + + res.writeHead(proxyRes.statusCode || 500, forwardHeaders); + proxyRes.pipe(res); + }); + + proxyReq.on('error', (err) => { + console.error('[AiDelegate] Proxy error:', err); + sendJson(res, 502, { error: 'Failed to reach AI provider' }); + }); + + proxyReq.write(body); + proxyReq.end(); + } catch (err) { + console.error('[AiDelegate] Proxy error:', err); + sendJson(res, 500, { error: 'Internal server error' }); + } + } } /** @@ -113,9 +514,7 @@ export function createMiddleware(options: AiDelegateServerOptions = {}) { res: ServerResponse, next: () => void ) { - // Express strips the mount path from req.url, so we use it directly - // (useOriginalUrl = false) - handler(req, res, false).then((handled) => { + handler(req, res).then((handled) => { if (!handled) { next(); } @@ -127,30 +526,16 @@ export function createMiddleware(options: AiDelegateServerOptions = {}) { } /** - * Creates a standalone HTTP request handler that can be used directly - * with http.createServer() or similar. - * - * Note: When using this standalone handler, you must handle URL routing yourself. - * The handler expects requests where req.url is already relative to the mount point. + * Creates a standalone HTTP request handler */ export function createRequestHandler(options: AiDelegateServerOptions = {}) { - const handler = createHandler(options); - - return async function requestHandler( - req: IncomingMessage, - res: ServerResponse - ): Promise { - // For standalone use, req.url should already be relative to the handler's mount point - return handler(req, res, false); - }; + return createHandler(options); } /** * Get the default dist path (from the package installation) */ function getDefaultDistPath(): string { - // When running from the package, dist should be relative to this file - // This file is in dist/server/index.js after compilation return path.resolve(__dirname, '..'); } -- GitLab From 83fe8986053844b8305fb4d975d4180a595a7cee Mon Sep 17 00:00:00 2001 From: Lembot Date: Fri, 12 Dec 2025 21:08:14 -0500 Subject: [PATCH 5/7] chore: remove separate credential-server docker directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credential server functionality is now integrated into the main ai-delegate server, so the separate docker directory is no longer needed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docker/credential-server/Dockerfile | 46 ----------------------------- docker/credential-server/server.js | 24 --------------- 2 files changed, 70 deletions(-) delete mode 100644 docker/credential-server/Dockerfile delete mode 100644 docker/credential-server/server.js diff --git a/docker/credential-server/Dockerfile b/docker/credential-server/Dockerfile deleted file mode 100644 index 88d21b7..0000000 --- a/docker/credential-server/Dockerfile +++ /dev/null @@ -1,46 +0,0 @@ -FROM node:24-alpine AS base - -WORKDIR /app - -# Install build dependencies for better-sqlite3 -RUN apk add --no-cache python3 make g++ - -# Copy source and install dependencies -COPY package*.json ./ -RUN npm ci --ignore-scripts - -COPY . . -RUN npm run build - -# Production stage -FROM node:24-alpine AS production - -WORKDIR /app - -# Install runtime dependencies for better-sqlite3 -RUN apk add --no-cache python3 make g++ - -# Copy only what's needed for production -COPY --from=base /app/package*.json ./ -COPY --from=base /app/node_modules ./node_modules -COPY --from=base /app/dist ./dist -COPY --from=base /app/docker/credential-server/server.js ./server.js - -# Create data directory for SQLite database -RUN mkdir -p /data - -# Environment variables -ENV PORT=3001 -ENV DB_PATH=/data/credentials.db -# CREDENTIAL_ENCRYPTION_SECRET must be provided at runtime - -EXPOSE 3001 - -# Volume for persistent data -VOLUME ["/data"] - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3001/health || exit 1 - -CMD ["node", "server.js"] diff --git a/docker/credential-server/server.js b/docker/credential-server/server.js deleted file mode 100644 index 2af1779..0000000 --- a/docker/credential-server/server.js +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node -/** - * AI Delegate Credential Server - * - * A secure server for storing encrypted API credentials and proxying - * requests to AI providers. Credentials never leave the server. - * - * Required environment variables: - * - CREDENTIAL_ENCRYPTION_SECRET: Secret key for encrypting stored credentials - * - * Optional environment variables: - * - PORT: Server port (default: 3001) - * - DB_PATH: Path to SQLite database (default: ./credentials.db) - */ - -// Check for required environment variable -if (!process.env.CREDENTIAL_ENCRYPTION_SECRET) { - console.error('ERROR: CREDENTIAL_ENCRYPTION_SECRET environment variable is required'); - console.error('Generate one with: openssl rand -hex 32'); - process.exit(1); -} - -// Start the credential server -require('./dist/credential-server/index.js').startServer(); -- GitLab From 8b5c2989b6951022d903a9bcee151c19bd20a082 Mon Sep 17 00:00:00 2001 From: Lembot Date: Fri, 12 Dec 2025 22:30:26 -0500 Subject: [PATCH 6/7] fix: use relative path for server module in Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker container doesn't have @peerverity/ai-delegate in node_modules since it's the package being built, not a dependency. Changed to use relative path ./dist/server/index.js instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docker/ai-delegate/server.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/ai-delegate/server.js b/docker/ai-delegate/server.js index 519f0bc..4dca17a 100644 --- a/docker/ai-delegate/server.js +++ b/docker/ai-delegate/server.js @@ -10,7 +10,8 @@ */ const http = require('http'); -const { createRequestHandler } = require('@peerverity/ai-delegate/server'); +// Use relative path since the package isn't published to npm +const { createRequestHandler } = require('./dist/server/index.js'); const PORT = process.env.PORT || 3000; const credentialsEnabled = !!process.env.CREDENTIAL_ENCRYPTION_SECRET; -- GitLab From 9cd81e1c1a76a5655599a8f9f8a570f24987d3f9 Mon Sep 17 00:00:00 2001 From: Lembot Date: Fri, 12 Dec 2025 23:44:07 -0500 Subject: [PATCH 7/7] feat: always use credential server, auto-generate encryption secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove "direct mode" - all requests now route through credential server - Auto-generate and persist encryption secret on first start if not provided - Default credentialServerUrl to current origin + endpoint path - Fix Dockerfile for better-sqlite3 native module on Alpine - Add better-sqlite3 to webpack externals for server bundle 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docker/ai-delegate/Dockerfile | 7 +++- docker/ai-delegate/server.js | 77 ++++++++++++++++++++++++++++++----- src/core/AiDelegate.ts | 14 ++++++- src/worker/worker.ts | 2 +- webpack.config.js | 3 +- 5 files changed, 89 insertions(+), 14 deletions(-) diff --git a/docker/ai-delegate/Dockerfile b/docker/ai-delegate/Dockerfile index 0acc0ff..1749fae 100644 --- a/docker/ai-delegate/Dockerfile +++ b/docker/ai-delegate/Dockerfile @@ -17,12 +17,17 @@ FROM node:24-alpine AS production WORKDIR /app +# Install runtime dependencies for native modules +RUN apk add --no-cache python3 make g++ + # Copy only what's needed for production COPY --from=base /app/package*.json ./ -COPY --from=base /app/node_modules ./node_modules COPY --from=base /app/dist ./dist COPY --from=base /app/docker/ai-delegate/server.js ./server.js +# Install production dependencies and rebuild native modules for Alpine +RUN npm ci --omit=dev --ignore-scripts && npm rebuild better-sqlite3 + # Create data directory for credentials database RUN mkdir -p /data diff --git a/docker/ai-delegate/server.js b/docker/ai-delegate/server.js index 4dca17a..dd3c44f 100644 --- a/docker/ai-delegate/server.js +++ b/docker/ai-delegate/server.js @@ -3,22 +3,81 @@ * AI Delegate Standalone Server * * A combined HTTP server that: - * 1. Serves the AI Delegate worker script - * 2. Handles credential storage and API proxying (when enabled) + * 1. Serves the AI Delegate web worker script + * 2. Handles credential storage and API proxying + * + * Credentials are always enabled. If no encryption secret is provided, + * one is auto-generated and persisted for consistency across restarts. * * Designed for Docker deployment. */ const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); // Use relative path since the package isn't published to npm const { createRequestHandler } = require('./dist/server/index.js'); const PORT = process.env.PORT || 3000; -const credentialsEnabled = !!process.env.CREDENTIAL_ENCRYPTION_SECRET; +const DATA_DIR = process.env.DATA_DIR || '/data'; +const SECRET_FILE = path.join(DATA_DIR, '.encryption-secret'); + +/** + * Get or generate the encryption secret. + * If CREDENTIAL_ENCRYPTION_SECRET env var is set, use that. + * Otherwise, check for a persisted secret file or generate a new one. + */ +function getOrCreateSecret() { + // First priority: environment variable + if (process.env.CREDENTIAL_ENCRYPTION_SECRET) { + console.log('[AiDelegate] Using encryption secret from environment variable'); + return process.env.CREDENTIAL_ENCRYPTION_SECRET; + } + + // Second priority: persisted secret file + try { + if (fs.existsSync(SECRET_FILE)) { + const secret = fs.readFileSync(SECRET_FILE, 'utf8').trim(); + if (secret.length >= 32) { + console.log('[AiDelegate] Using persisted encryption secret'); + return secret; + } + } + } catch (err) { + console.warn('[AiDelegate] Could not read secret file:', err.message); + } + // Generate new secret + const newSecret = crypto.randomBytes(32).toString('hex'); + console.log('[AiDelegate] Generated new encryption secret'); + + // Persist it + try { + // Ensure data directory exists + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + fs.writeFileSync(SECRET_FILE, newSecret, { mode: 0o600 }); + console.log('[AiDelegate] Persisted encryption secret to', SECRET_FILE); + } catch (err) { + console.warn('[AiDelegate] Could not persist secret:', err.message); + console.warn('[AiDelegate] Secret will be regenerated on restart (existing credentials will be lost)'); + } + + return newSecret; +} + +// Get or create the encryption secret +const encryptionSecret = getOrCreateSecret(); + +// Set the environment variable so the credential database can use it +process.env.CREDENTIAL_ENCRYPTION_SECRET = encryptionSecret; + +// Credentials are always enabled const handler = createRequestHandler({ - enableCredentials: credentialsEnabled, - dbPath: process.env.DB_PATH || './credentials.db', + enableCredentials: true, + dbPath: process.env.DB_PATH || path.join(DATA_DIR, 'credentials.db'), }); const server = http.createServer(async (req, res) => { @@ -33,9 +92,7 @@ const server = http.createServer(async (req, res) => { server.listen(PORT, '0.0.0.0', () => { console.log(`AI Delegate server running on port ${PORT}`); console.log(`Worker available at: http://0.0.0.0:${PORT}/ai-delegate-worker.js`); - if (credentialsEnabled) { - console.log(`Credential server enabled:`); - console.log(` Setup page: http://0.0.0.0:${PORT}/setup`); - console.log(` Proxy endpoint: http://0.0.0.0:${PORT}/proxy`); - } + console.log(`Credential server enabled:`); + console.log(` Setup page: http://0.0.0.0:${PORT}/setup`); + console.log(` Proxy endpoint: http://0.0.0.0:${PORT}/proxy`); }); diff --git a/src/core/AiDelegate.ts b/src/core/AiDelegate.ts index 07dbf2c..bd06b48 100644 --- a/src/core/AiDelegate.ts +++ b/src/core/AiDelegate.ts @@ -46,6 +46,9 @@ export class AiDelegate { * Initialize the AI Delegate with required options. * This MUST be called before any other method. * + * The credential server is always used for API requests. If credentialServerUrl + * is not explicitly provided, it defaults to the current origin + endpoint path. + * * @param options - Initialization options including the endpoint path * @throws Error if endpoint is not provided */ @@ -64,7 +67,16 @@ export class AiDelegate { } this.endpoint = endpoint; - this.credentialServerUrl = options.credentialServerUrl; + + // Default to using the endpoint as credential server URL if not explicitly provided + // This enables credential server mode by default since the credential server + // is served at the same endpoint as the worker + if (options.credentialServerUrl !== undefined) { + this.credentialServerUrl = options.credentialServerUrl; + } else { + // Use current origin + endpoint path as credential server URL + this.credentialServerUrl = `${window.location.origin}${endpoint}`; + } // If init is called multiple times, just return the existing promise if (this.initPromise) { diff --git a/src/worker/worker.ts b/src/worker/worker.ts index 8df2c34..ac66716 100644 --- a/src/worker/worker.ts +++ b/src/worker/worker.ts @@ -31,7 +31,7 @@ function initializeDelegate(credentialServerUrl?: string): void { delegate.setPromptHandler(workerPromptHandler); isInitialized = true; - console.log('[AI Delegate Worker] Initialized', credentialServerUrl ? `with credential server: ${credentialServerUrl}` : 'in direct mode'); + console.log('[AI Delegate Worker] Initialized with credential server:', credentialServerUrl || '(not configured)'); } /** diff --git a/webpack.config.js b/webpack.config.js index 9a89a60..6e9004d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -72,7 +72,8 @@ module.exports = [ // Don't bundle Node.js built-in modules fs: 'commonjs fs', path: 'commonjs path', - http: 'commonjs http' + http: 'commonjs http', + 'better-sqlite3': 'commonjs better-sqlite3' }, resolve: { extensions: ['.ts', '.js'] -- GitLab