Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 0 additions & 7 deletions database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
-- ============================================================================
Expand Down
5 changes: 3 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion docs/QUICK_START.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions src/api/__tests__/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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';

Expand All @@ -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();

Expand Down
26 changes: 13 additions & 13 deletions src/api/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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!;
}
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 42 additions & 5 deletions src/dashboard/admin-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class AdminServer {
private syncScheduler?: ISyncScheduler;
private db: Pool;
private redis: RedisClientType;
private adminApiToken?: string;

constructor(
dashboard: IDashboard,
Expand All @@ -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();
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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,
Expand All @@ -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) => {
Expand Down
4 changes: 4 additions & 0 deletions src/dashboard/public/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,10 @@ <h2>
<strong id="preset-info-title"></strong>
<p id="preset-info-desc" style="margin: 8px 0 0 0; font-size: 0.9rem; color: var(--text-muted);"></p>
</div>
<div class="form-group">
<label>Admin API Token</label>
<input id="query-admin-token" type="password" placeholder="Required for dashboard test queries" autocomplete="off" style="width: 100%; padding: 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-secondary); color: var(--text-primary); font-family: inherit;">
</div>
<div class="form-group">
<label>Your Query</label>
<textarea id="query-input" rows="4" placeholder="Enter your question or request here..." style="width: 100%; padding: 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-secondary); color: var(--text-primary); font-family: inherit; resize: vertical;"></textarea>
Expand Down
38 changes: 24 additions & 14 deletions src/dashboard/public/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -1229,6 +1245,9 @@ async function sendTestQuery() {

try {
// Build request body
const proxyHeaders = getAdminProxyHeaders({
"Content-Type": "application/json",
});
const requestBody = {
query,
transparency,
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -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) {
Expand Down
Loading