diff --git a/.env.example b/.env.example index 61747f7..092645a 100644 --- a/.env.example +++ b/.env.example @@ -59,7 +59,11 @@ API_HOST=0.0.0.0 # JWT Secret for API authentication (REQUIRED in production) # Generate a secure random string: openssl rand -base64 32 -JWT_SECRET=change-this-to-a-secure-random-string-in-production +JWT_SECRET= + +# Admin dashboard token for server-side API Gateway proxying +# Generate a secure random string: openssl rand -base64 32 +ADMIN_API_TOKEN= # ---------------------------------------------------------------------------- # AI Provider API Keys diff --git a/database/schema.sql b/database/schema.sql index 46bbe6d..57c80f8 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -261,13 +261,6 @@ CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash); CREATE INDEX idx_api_keys_user_id ON api_keys(user_id); CREATE INDEX idx_api_keys_active ON api_keys(active); --- Seed demo API key for UI (development/testing only) --- Key: demo-api-key-for-testing-purposes-only-12345678901234567890 --- Hash: 52346957575b04c715942a324887efde06f034ca62893fa6a76064d7f65f8e43 -INSERT INTO api_keys (user_id, key_hash, active) -VALUES ('demo-user', '52346957575b04c715942a324887efde06f034ca62893fa6a76064d7f65f8e43', true) -ON CONFLICT (key_hash) DO NOTHING; - -- ============================================================================ -- Iterative Consensus tables -- ============================================================================ diff --git a/docker-compose.yml b/docker-compose.yml index 85a66dd..5b77df4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,8 +85,8 @@ services: # API Configuration API_PORT: ${API_PORT:-3000} API_HOST: ${API_HOST:-0.0.0.0} - JWT_SECRET: ${JWT_SECRET:-change-this-in-production} - ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:-admin-test-key} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} + ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?ADMIN_API_TOKEN is required} # Provider API Keys (required) OPENAI_API_KEY: ${OPENAI_API_KEY} @@ -190,6 +190,7 @@ services: # API Gateway connection (for proxying /api/v1/* requests) API_INTERNAL_HOST: api API_PORT: 3000 + ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?ADMIN_API_TOKEN is required} # Provider API Keys (for health checks) OPENAI_API_KEY: ${OPENAI_API_KEY} diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index 5e4000c..cafb345 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -24,7 +24,7 @@ Edit `.env` and add your OpenRouter API key: ```bash # REQUIRED: OpenRouter API key (unified access to all providers) -OPENROUTER_API_KEY=sk-or-v1-your-openrouter-key-here +OPENROUTER_API_KEY=your-openrouter-key-here # Get your key at: https://openrouter.ai/keys diff --git a/src/api/__tests__/gateway.test.ts b/src/api/__tests__/gateway.test.ts index 179dcf7..1ab8ee5 100644 --- a/src/api/__tests__/gateway.test.ts +++ b/src/api/__tests__/gateway.test.ts @@ -101,7 +101,7 @@ describe('APIGateway - Error Paths and Edge Cases', () => { mockRedis, mockDbPool ); - }).toThrow('JWT_SECRET environment variable is required in production'); + }).toThrow('JWT_SECRET environment variable or constructor parameter is required'); }); it('should use environment JWT_SECRET if provided', () => { @@ -118,7 +118,7 @@ describe('APIGateway - Error Paths and Edge Cases', () => { expect(gatewayInstance).toBeDefined(); }); - it('should warn and use default JWT_SECRET in development when not provided', () => { + it('should warn and generate a development JWT_SECRET when not provided', () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); process.env.NODE_ENV = 'development'; @@ -131,7 +131,7 @@ describe('APIGateway - Error Paths and Edge Cases', () => { ); expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARNING: Using default JWT_SECRET') + expect.stringContaining('WARNING: Generated ephemeral JWT_SECRET') ); expect(gatewayInstance).toBeDefined(); diff --git a/src/api/gateway.ts b/src/api/gateway.ts index be11c4c..a560547 100644 --- a/src/api/gateway.ts +++ b/src/api/gateway.ts @@ -7,7 +7,7 @@ import express, { Express, Request, Response, NextFunction } from 'express'; import cors from 'cors'; import rateLimit from 'express-rate-limit'; import jwt from 'jsonwebtoken'; -import { randomUUID, createHash } from 'crypto'; +import { randomUUID, createHash, randomBytes } from 'crypto'; import { Server } from 'http'; import { RedisClientType } from 'redis'; import { Pool } from 'pg'; @@ -94,18 +94,17 @@ export class APIGateway implements IAPIGateway { this.idempotencyCache = idempotencyCache; this.modelRegistry = modelRegistry; - // Require JWT_SECRET environment variable in production + if (!jwtSecret && !process.env.JWT_SECRET && process.env.NODE_ENV === 'production') { + throw new Error( + 'JWT_SECRET environment variable or constructor parameter is required' + ); + } + if (!jwtSecret && !process.env.JWT_SECRET) { - if (process.env.NODE_ENV === 'production') { - throw new Error( - 'JWT_SECRET environment variable is required in production' - ); - } - // Only allow default in development console.warn( - 'WARNING: Using default JWT_SECRET. Set JWT_SECRET environment variable in production!' + 'WARNING: Generated ephemeral JWT_SECRET for development/test use. Set JWT_SECRET in persistent environments.' ); - this.jwtSecret = 'default-secret-change-in-production'; + this.jwtSecret = randomBytes(32).toString('hex'); } else { this.jwtSecret = jwtSecret || process.env.JWT_SECRET!; } @@ -313,9 +312,10 @@ export class APIGateway implements IAPIGateway { return; } - // Check for admin dashboard token (internal use only) - const adminToken = process.env.ADMIN_API_TOKEN || 'admin-test-key'; - if (apiKey === adminToken) { + // Check for admin dashboard token (internal use only). There is no + // fallback token: deployments must opt in by setting ADMIN_API_TOKEN. + const adminToken = process.env.ADMIN_API_TOKEN?.trim(); + if (adminToken && apiKey === adminToken) { req.userId = 'admin-dashboard'; next(); return; diff --git a/src/dashboard/admin-server.ts b/src/dashboard/admin-server.ts index 4f7d714..f3bb5bc 100644 --- a/src/dashboard/admin-server.ts +++ b/src/dashboard/admin-server.ts @@ -30,6 +30,7 @@ export class AdminServer { private syncScheduler?: ISyncScheduler; private db: Pool; private redis: RedisClientType; + private adminApiToken?: string; constructor( dashboard: IDashboard, @@ -46,6 +47,7 @@ export class AdminServer { this.syncScheduler = syncScheduler; this.db = db; this.redis = redis; + this.adminApiToken = process.env.ADMIN_API_TOKEN?.trim(); this.setupMiddleware(); this.setupRoutes(); @@ -81,7 +83,11 @@ export class AdminServer { }); // Proxy /api/v1/* requests to the API gateway - this.app.use('/api/v1', this.proxyToApiGateway.bind(this)); + this.app.use( + '/api/v1', + this.authenticateAdminProxy.bind(this), + this.proxyToApiGateway.bind(this) + ); // Overview metrics this.app.get('/api/admin/overview', this.getOverview.bind(this)); @@ -143,6 +149,35 @@ export class AdminServer { * Proxy requests to the API Gateway for /api/v1/* endpoints * This allows the Test Query feature to work from the admin dashboard */ + private authenticateAdminProxy( + req: Request, + res: Response, + next: NextFunction + ): void { + if (!this.adminApiToken) { + res.status(503).json({ + error: { + code: 'ADMIN_API_TOKEN_NOT_CONFIGURED', + message: 'Admin API token is not configured' + } + }); + return; + } + + const authHeader = req.headers.authorization?.trim(); + if (authHeader !== `ApiKey ${this.adminApiToken}`) { + res.status(401).json({ + error: { + code: 'ADMIN_AUTHENTICATION_REQUIRED', + message: 'Admin authorization is required' + } + }); + return; + } + + next(); + } + private proxyToApiGateway( req: Request, res: Response, @@ -154,15 +189,17 @@ export class AdminServer { // For Docker internal networking, use service name const targetHost = process.env.API_INTERNAL_HOST || apiHost; + const headers: http.OutgoingHttpHeaders = { + ...req.headers, + host: `${targetHost}:${apiPort}` + }; + const options: http.RequestOptions = { hostname: targetHost, port: parseInt(apiPort), path: `/api/v1${req.url}`, method: req.method, - headers: { - ...req.headers, - host: `${targetHost}:${apiPort}` - } + headers }; const proxyReq = http.request(options, (proxyRes) => { diff --git a/src/dashboard/public/admin.html b/src/dashboard/public/admin.html index 7039600..04dd154 100644 --- a/src/dashboard/public/admin.html +++ b/src/dashboard/public/admin.html @@ -853,6 +853,10 @@

+
+ + +
diff --git a/src/dashboard/public/admin.js b/src/dashboard/public/admin.js index 072f1f1..6f375ad 100644 --- a/src/dashboard/public/admin.js +++ b/src/dashboard/public/admin.js @@ -11,6 +11,22 @@ let currentTab = "overview"; let refreshInterval = null; +function getAdminProxyHeaders(extraHeaders = {}) { + const tokenInput = document.getElementById("query-admin-token"); + const token = tokenInput?.value || ""; + + if (!token || !token.trim()) { + if (tokenInput) tokenInput.focus(); + throw new Error("Admin API token is required for test queries"); + } + + return { + ...extraHeaders, + Authorization: `ApiKey ${token.trim()}`, + }; +} + + // Initialize on page load document.addEventListener("DOMContentLoaded", () => { setupTabs(); @@ -1229,6 +1245,9 @@ async function sendTestQuery() { try { // Build request body + const proxyHeaders = getAdminProxyHeaders({ + "Content-Type": "application/json", + }); const requestBody = { query, transparency, @@ -1242,10 +1261,7 @@ async function sendTestQuery() { try { submitResponse = await fetch("/api/v1/requests", { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "ApiKey admin-test-key", - }, + headers: proxyHeaders, body: JSON.stringify(requestBody), }); } catch (fetchError) { @@ -1311,9 +1327,7 @@ async function sendTestQuery() { try { const statusResponse = await fetch(`/api/v1/requests/${requestId}`, { - headers: { - Authorization: "ApiKey admin-test-key", - }, + headers: proxyHeaders, }); if (statusResponse.ok) { @@ -1370,7 +1384,7 @@ async function sendTestQuery() { } // Display response - displayQueryResponse(result, requestId, transparency); + displayQueryResponse(result, requestId, transparency, proxyHeaders); } catch (error) { console.error("Error sending query:", error); document.getElementById("query-loading").style.display = "none"; @@ -1386,7 +1400,7 @@ async function sendTestQuery() { /** * Display query response */ -async function displayQueryResponse(result, requestId, showDetails) { +async function displayQueryResponse(result, requestId, showDetails, proxyHeaders) { const responseDiv = document.getElementById("query-response"); const statusDiv = document.getElementById("query-status"); const consensusDiv = document.getElementById("query-consensus"); @@ -1416,11 +1430,7 @@ async function displayQueryResponse(result, requestId, showDetails) { try { const deliberationResponse = await fetch( `/api/v1/requests/${requestId}/deliberation`, - { - headers: { - Authorization: "ApiKey admin-test-key", - }, - }, + { headers: proxyHeaders }, ); if (deliberationResponse.ok) { diff --git a/src/ui/interface.ts b/src/ui/interface.ts index e0a3efa..d08a98d 100644 --- a/src/ui/interface.ts +++ b/src/ui/interface.ts @@ -304,9 +304,9 @@ export class UserInterface { box-shadow: 0 0 30px rgba(0, 212, 255, 0.2); } - .query-textarea { + .query-textarea, + .api-key-input { width: 100%; - min-height: 140px; padding: 20px; background: var(--bg-secondary); border: none; @@ -314,12 +314,19 @@ export class UserInterface { font-family: inherit; font-size: 16px; color: var(--text-primary); - resize: vertical; transition: all var(--transition-medium); } - .query-textarea::placeholder { color: var(--text-muted); } - .query-textarea:focus { outline: none; background: var(--bg-tertiary); } + .query-textarea { + min-height: 140px; + resize: vertical; + } + + .api-key-input { height: 58px; } + .query-textarea::placeholder, + .api-key-input::placeholder { color: var(--text-muted); } + .query-textarea:focus, + .api-key-input:focus { outline: none; background: var(--bg-tertiary); } .button-group { display: flex; @@ -774,6 +781,23 @@ export class UserInterface {
+
+
+ 🔐 + API Key +
+
+ +
+
+
@@ -845,6 +869,18 @@ export class UserInterface { let currentRequestId = null; let deliberationVisible = false; + let activeApiKey = null; + + function getApiKey() { + const apiKeyInput = document.getElementById('apiKey'); + const apiKey = apiKeyInput ? apiKeyInput.value.trim() : ''; + if (!apiKey) { + showStatus('Please enter an API key', 'error'); + if (apiKeyInput) apiKeyInput.focus(); + return null; + } + return apiKey; + } async function loadConfig() { try { @@ -869,6 +905,10 @@ export class UserInterface { return; } + const apiKey = getApiKey(); + if (!apiKey) return; + activeApiKey = apiKey; + const submitBtn = document.getElementById('submitBtn'); submitBtn.disabled = true; document.getElementById('submitBtnText').innerHTML = 'Processing...'; @@ -878,7 +918,6 @@ export class UserInterface { deliberationVisible = false; try { - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; const response = await fetch(\`\${config.apiBaseUrl}/api/v1/requests\`, { method: 'POST', headers: { @@ -903,7 +942,7 @@ export class UserInterface { const data = await response.json(); currentRequestId = data.requestId; showStatus('Processing your request...', 'processing'); - pollForResponse(currentRequestId); + pollForResponse(currentRequestId, apiKey); } catch (error) { showStatus(\`Error: \${error.message}\`, 'error'); submitBtn.disabled = false; @@ -911,8 +950,7 @@ export class UserInterface { } } - async function pollForResponse(requestId) { - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; + async function pollForResponse(requestId, apiKey) { const maxAttempts = 120; let attempts = 0; @@ -936,7 +974,7 @@ export class UserInterface { if (data.status === 'completed') { if (!currentRequestId) currentRequestId = requestId; - displayResponse(data.consensusDecision); + displayResponse(data.consensusDecision, apiKey); hideStatus(); enableNewRequest(); } else if (data.status === 'failed') { @@ -978,7 +1016,7 @@ export class UserInterface { return '

' + formatted + '

'; } - function displayResponse(decision) { + function displayResponse(decision, apiKey = activeApiKey) { const responseSection = document.getElementById('responseSection'); const responseContent = document.getElementById('responseContent'); @@ -998,10 +1036,10 @@ export class UserInterface { if (decision && typeof decision === 'object' && decision.iterativeConsensusMetadata) { displayConsensusMetadata(decision.iterativeConsensusMetadata); - if (currentRequestId) setTimeout(() => loadNegotiationDetails(currentRequestId), 100); + if (currentRequestId && apiKey) setTimeout(() => loadNegotiationDetails(currentRequestId, apiKey), 100); } - if (currentRequestId) setTimeout(() => loadDeliberationData(currentRequestId), 100); + if (currentRequestId && apiKey) setTimeout(() => loadDeliberationData(currentRequestId, apiKey), 100); } function displayConsensusMetadata(metadata) { @@ -1042,9 +1080,9 @@ export class UserInterface { metadataSection.classList.add('visible'); } - async function loadNegotiationDetails(requestId) { + async function loadNegotiationDetails(requestId, apiKey) { try { - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; + if (!apiKey) return; const response = await fetch(\`\${config.apiBaseUrl}/api/v1/requests/\${requestId}/negotiation\`, { headers: { 'Authorization': \`ApiKey \${apiKey}\` } }); @@ -1083,9 +1121,9 @@ export class UserInterface { negotiationSection.classList.add('visible'); } - async function loadDeliberationData(requestId) { + async function loadDeliberationData(requestId, apiKey) { try { - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; + if (!apiKey) return; const response = await fetch(\`\${config.apiBaseUrl}/api/v1/requests/\${requestId}/deliberation\`, { headers: { 'Authorization': \`ApiKey \${apiKey}\` } }); @@ -1160,7 +1198,7 @@ export class UserInterface { if (deliberationVisible) { deliberationSection.classList.add('visible'); transparencyBtn.textContent = 'Hide Deliberation'; - if (currentRequestId) loadDeliberationData(currentRequestId); + if (currentRequestId && activeApiKey) loadDeliberationData(currentRequestId, activeApiKey); } else { deliberationSection.classList.remove('visible'); transparencyBtn.textContent = 'Show Deliberation'; @@ -1197,6 +1235,7 @@ export class UserInterface { document.getElementById('newRequestBtn').classList.add('hidden'); hideStatus(); currentRequestId = null; + activeApiKey = null; deliberationVisible = false; }