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 {