diff --git a/docker/ai-delegate/Dockerfile b/docker/ai-delegate/Dockerfile index ec885b63911c9b6208488c98cf61ae72d0596b80..1749fae5e0cefbfa5b3cf983418d791aef608fa3 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 @@ -14,13 +17,26 @@ 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/dist ./dist COPY --from=base /app/docker/ai-delegate/server.js ./server.js -# Default port +# 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 + +# 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 5012ac5ba6240fb1a14967e7fcb623fffa92bc3a..dd3c44fd23c2574a318704b585e0598203c87f67 100644 --- a/docker/ai-delegate/server.js +++ b/docker/ai-delegate/server.js @@ -2,25 +2,85 @@ /** * 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 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 { createRequestHandler } = require('@peerverity/ai-delegate/server'); +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 handler = createRequestHandler(); +const DATA_DIR = process.env.DATA_DIR || '/data'; +const SECRET_FILE = path.join(DATA_DIR, '.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; +/** + * 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)'); } - // Try to handle with ai-delegate + 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: true, + dbPath: process.env.DB_PATH || path.join(DATA_DIR, 'credentials.db'), +}); + +const server = http.createServer(async (req, res) => { const handled = await handler(req, res); if (!handled) { @@ -32,4 +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`); + 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/package-lock.json b/package-lock.json index 7b757f6c7210208787763882ecf832108a75c0e3..4d5af2fa33af050a81c53cc1a6bda37e5841708f 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 00ba3a7599f15b1fa3684717d5a43e33c7eb9071..15b4430637d1d6073c82a0cad5000a67436dfc6c 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 6643527e5e02dbce256a76ac2071ba91a09ddbf2..bd06b4820b252454b8983b0f652c27c61afb778a 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() { @@ -45,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,6 +68,16 @@ export class AiDelegate { this.endpoint = endpoint; + // 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) { return this.initPromise; @@ -89,7 +103,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 75afcf0553ff9668cf21b06ee8b3236f1e2ddec3..6e51f860e0416b892f33c71255edbf370678083c 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 0000000000000000000000000000000000000000..8f8a07f764cd43c182a217b33d814592bb6fd78e --- /dev/null +++ b/src/credential-server/db.ts @@ -0,0 +1,282 @@ +/** + * 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; + keySalt: string; // Per-user salt for key derivation + createdAt: number; + updatedAt: number; +} + +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 masterSecret: string; + + 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'); + } + + 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' + ); + } + + private initSchema(): void { + this.db.exec(` + 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 + ); + + 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 with the provided key + */ + private encrypt(plaintext: string, key: Buffer): string { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, 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 using the provided key + */ + 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, key, 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 + * Returns the user and a session key part that must be stored in the session cookie + */ + createUser(apiKey: string): { user: User; sessionKeyPart: string } { + const id = crypto.randomUUID(); + const now = Date.now(); + + // 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, key_salt, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `); + + stmt.run(id, encryptedApiKey, keySalt, now, now); + + return { + user: { + id, + encryptedApiKey, + keySalt, + createdAt: now, + updatedAt: now + }, + sessionKeyPart + }; + } + + /** + * Get decrypted API key for a user using their session key part + */ + 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; + } + + // 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 using their session key part + */ + 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(` + 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 + * The keyPart is stored in the session cookie, not in the database + */ + 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; + + 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, + keyPart, // Returned but NOT stored in DB - must be stored in cookie + createdAt: now, + expiresAt + }; + } + + /** + * 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, keyPart: 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, + keyPart, // From cookie, not from DB + 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 0000000000000000000000000000000000000000..f11b0c40d68ecca63c57b7c460d7fe9b92988a8b --- /dev/null +++ b/src/credential-server/index.ts @@ -0,0 +1,476 @@ +/** + * 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 with session ID and key part + * Format: sessionId.keyPart (base64 encoded) + */ +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}=${cookieValue}`, + 'HttpOnly', + 'Path=/', + `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, + 'SameSite=Strict' + ]; + + if (isProduction) { + cookieOptions.push('Secure'); + } + + 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 + */ +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 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 + // 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, session.keyPart); + 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 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; + } + + // 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 or decryption failed' }); + 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 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 + }); +} + +/** + * 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 0000000000000000000000000000000000000000..b056fee541ce78a06e802ef04d6aad43ac78a9f8 --- /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/server/index.ts b/src/server/index.ts index 0dcb0cf1bfdcdff8bb40b2742f6d9b1f44cc9522..18c24ffdd23d9780a8f65b44e299f2971cef2b1b 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, '..'); } diff --git a/src/types/index.ts b/src/types/index.ts index c4fce0d5d026d24803853233ed8b2f8e0996e7b3..64e1b45566cd21d0426400f268046c87930d7b3e 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 a6c27c36bc7ad3bfbf1b4fd564482915c00df5c2..eed446d3ebde9a9538c5056d9efd8e760dee9352 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 b57496da7879d77964eec501859d3ae586545caf..cb6519b8d6962040686358c85c1f329e63301fe9 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 757f99f45c9168689c0f86568a60f57d12115755..ac6671601cefae322799dc39d2186c7de118f481 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 with credential server:', credentialServerUrl || '(not configured)'); +} + +/** + * 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