This guide documents the actual implementation patterns used in the Conduit WebAdmin (Next.js). The WebAdmin uses a minimal API architecture where most business logic happens client-side using SDK clients with ephemeral authentication keys.
Last Updated: 2025-11-08 Status: ✅ Accurate and Current
- Architecture Overview
- The Three API Routes
- Authentication Pattern
- SDK Client Usage
- Error Handling
- Ephemeral Key Strategy
- Route Implementation Patterns
- Environment Configuration
- Best Practices
- Common Patterns
The WebAdmin follows a minimal server-side API approach:
- Only 3 server-side API routes - All for authentication/key generation
- Client-side SDK usage - Browser communicates directly with Core/Admin APIs
- Ephemeral key authentication - Short-lived keys for secure client-side access
- Simple, direct patterns - No complex wrappers or middleware layers
┌─────────────┐
│ Browser │
└──────┬──────┘
│ 1. Request ephemeral key
▼
┌─────────────────┐
│ WebAdmin API │ ── Clerk Auth ──┐
│ (3 routes) │ │
└────────┬────────┘ │
│ 2. Returns key + API URL │
▼ ▼
┌─────────────────┐ ┌──────────┐
│ Browser with │ │ Clerk │
│ SDK + Temp Key │ │Middleware│
└────────┬────────┘ └──────────┘
│
│ 3. Direct API calls with ephemeral key
▼
┌─────────────────┐
│ Core/Admin API │
└─────────────────┘
The WebAdmin has exactly three API routes:
Purpose: Health status endpoint Auth: None required Method: GET
// src/app/api/health/route.ts
export async function GET() {
try {
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage().rss
});
} catch {
return NextResponse.json(
{ status: 'unhealthy', timestamp: new Date().toISOString() },
{ status: 500 }
);
}
}Purpose: Generate ephemeral keys for Gateway API access Auth: Clerk (via middleware) Method: POST
// src/app/api/auth/ephemeral-key/route.ts
export async function POST(request: NextRequest) {
try {
const body = await request.json() as EphemeralKeyRequest;
// Get WebAdmin's virtual key
const adminClient = getServerAdminClient();
const webadminVirtualKey = await adminClient.system.getWebAdminVirtualKey();
// Extract request metadata
const sourceIP = request.headers.get('x-forwarded-for') ??
request.headers.get('x-real-ip') ??
'unknown';
const userAgent = request.headers.get('user-agent') ?? 'unknown';
// Generate ephemeral key via Core SDK
const coreClient = await getServerCoreClient();
const response = await coreClient.auth.generateEphemeralKey(webadminVirtualKey, {
metadata: {
sourceIP,
userAgent,
purpose: body.purpose ?? 'web-ui-request'
}
});
return NextResponse.json({
...response,
coreApiUrl: process.env.CONDUIT_API_EXTERNAL_URL ?? 'http://localhost:5000',
});
} catch (error) {
console.error('Error generating ephemeral key:', error);
return handleSDKError(error);
}
}Purpose: Generate ephemeral master keys for Admin API access Auth: Clerk (via middleware) Method: POST
// src/app/api/auth/ephemeral-master-key/route.ts
export async function POST(request: NextRequest) {
try {
const masterKey = process.env.CONDUIT_API_TO_API_BACKEND_AUTH_KEY;
if (!masterKey) {
return NextResponse.json(
{ error: 'Master key not configured' },
{ status: 500 }
);
}
const isDevelopment = process.env.CLERK_AUTH_ENABLED !== 'true';
if (isDevelopment) {
// Development: return master key directly
return NextResponse.json({
ephemeralMasterKey: masterKey,
expiresAt: new Date(Date.now() + 3600000).toISOString(),
expiresInSeconds: 3600,
adminApiUrl: process.env.CONDUIT_ADMIN_API_EXTERNAL_URL ?? 'http://localhost:5002'
});
}
// Production: call Admin API to generate ephemeral key
const adminApiUrl = process.env.CONDUIT_ADMIN_API_BASE_URL ?? 'http://admin-api:5002';
const response = await fetch(`${adminApiUrl}/api/admin/auth/ephemeral-master-key`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Master-Key': masterKey,
},
body: JSON.stringify({
metadata: {
sourceIP: request.headers.get('x-forwarded-for') ?? 'unknown',
userAgent: request.headers.get('user-agent') ?? 'unknown',
purpose: 'web-ui-request'
}
})
});
if (!response.ok) {
throw new Error(`Failed to generate ephemeral master key: ${response.status}`);
}
const data = await response.json();
return NextResponse.json({
...data,
adminApiUrl: process.env.CONDUIT_ADMIN_API_EXTERNAL_URL ?? 'http://localhost:5002'
});
} catch (error) {
console.error('Error generating ephemeral master key:', error);
return handleSDKError(error);
}
}Authentication is handled at the request level via Clerk middleware, not per-route.
File: src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
// Public routes that don't require authentication
const isPublicRoute = createRouteMatcher([
'/access-denied',
'/api/health', // Add public routes here
]);
export default clerkMiddleware(async (auth, req) => {
// Skip all auth in development when explicitly disabled
if (process.env.CLERK_AUTH_ENABLED !== 'true' && process.env.NODE_ENV === 'development') {
return NextResponse.next();
}
if (!isPublicRoute(req)) {
const { userId, sessionClaims, redirectToSignIn } = await auth();
// If not authenticated, redirect to sign-in
if (!userId) {
return redirectToSignIn();
}
// Check if user has admin access
const metadata = sessionClaims?.metadata as { siteadmin?: boolean } | undefined;
const isAdmin = metadata?.siteadmin === true;
// If not admin, redirect to access-denied
if (!isAdmin) {
return NextResponse.redirect(new URL('/access-denied', req.url));
}
}
});- ✅ No per-route authentication - Middleware handles it globally
- ✅ Public routes defined in
isPublicRoutematcher - ✅ Development mode can skip auth when
CLERK_AUTH_ENABLED !== 'true' - ✅ Admin-only access - Only users with
siteadminmetadata can access - ✅ Route handlers assume authenticated - If middleware lets request through, user is authenticated
File: src/lib/server/sdk-config.ts
import { getServerAdminClient } from '@/lib/server/sdk-config';
export async function GET() {
try {
const adminClient = getServerAdminClient();
const providers = await adminClient.providers.list();
return NextResponse.json(providers);
} catch (error) {
return handleSDKError(error);
}
}import { getServerCoreClient } from '@/lib/server/sdk-config';
export async function POST(request: NextRequest) {
try {
const coreClient = await getServerCoreClient(); // Note: async!
const response = await coreClient.chat.create({ /* ... */ });
return NextResponse.json(response);
} catch (error) {
return handleSDKError(error);
}
}getServerAdminClient()is synchronous - returns client directlygetServerCoreClient()is async - must await- Both are singletons - don't create new clients manually
- Never expose master keys to client
File: src/lib/errors/sdk-errors.ts
All SDK errors should use the centralized error handler:
import { handleSDKError } from '@/lib/errors/sdk-errors';
export async function POST(request: NextRequest) {
try {
const client = getServerAdminClient();
const result = await client.someOperation();
return NextResponse.json(result);
} catch (error) {
return handleSDKError(error); // Automatically maps to correct HTTP status
}
}| SDK Error Type | HTTP Status | Response |
|---|---|---|
| ValidationError | 400 | { error: message } |
| AuthError | 401 | { error: message } |
| NotFoundError | 404 | { error: message } |
| ConflictError | 409 | { error: message } |
| RateLimitError | 429 | { error: message } |
| ServerError | 500 | { error: message } |
| Network errors (ECONNREFUSED) | 503 | Service unavailable |
| Timeout errors (ETIMEDOUT) | 504 | Gateway timeout |
| Unknown | 500 | Internal server error |
import {
getErrorMessage,
getErrorStatusCode,
isHttpError,
getCombinedErrorDetails
} from '@/lib/utils/error-utils';
// Extract status code
const status = getErrorStatusCode(error) ?? 500;
// Get error message safely
const message = getErrorMessage(error);
// Check if it's an HTTP error
if (isHttpError(error)) {
// Handle HTTP-specific error
}
// Get full error details
const details = getCombinedErrorDetails(error);- Security - Short-lived, single-purpose keys minimize exposure
- Direct API access - Browser can call Core/Admin APIs directly
- Scalability - No need to proxy all API traffic through WebAdmin
- Simplicity - Clean separation between auth and business logic
1. Browser needs to call Gateway API
2. Request ephemeral key from /api/auth/ephemeral-key
3. WebAdmin validates user via Clerk
4. WebAdmin generates ephemeral key using its virtual key
5. Returns ephemeral key + Gateway API URL to browser
6. Browser stores key in memory (with expiration)
7. Browser makes direct calls to Gateway API with ephemeral key
8. When key expires or gets 401, browser requests new key
File: src/lib/client/ephemeralKeyClient.ts
import { ephemeralKeyClient } from '@/lib/client/ephemeralKeyClient';
// Get a valid ephemeral key (from cache or refreshed)
const { key, coreApiUrl } = await ephemeralKeyClient.getKey();
// Make a direct request to Gateway API
const response = await ephemeralKeyClient.makeDirectRequest('/api/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ /* request */ }),
});- Keys are cached in memory with expiration timestamp
- 30-second buffer before actual expiration for safety
- Automatically refresh when approaching expiration
- On 401 errors, clear cache and retry with fresh key
- Race condition protection: concurrent refresh requests share same promise
import { NextRequest, NextResponse } from 'next/server';
import { handleSDKError } from '@/lib/errors/sdk-errors';
import { getServerAdminClient } from '@/lib/server/sdk-config';
export async function GET(request: NextRequest) {
try {
const client = getServerAdminClient();
const data = await client.someService.list();
return NextResponse.json(data);
} catch (error) {
return handleSDKError(error);
}
}export async function POST(request: NextRequest) {
try {
const body = await request.json() as MyRequestType;
// Validate input
if (!body.name?.trim()) {
return NextResponse.json(
{ error: 'Name is required' },
{ status: 400 }
);
}
const client = getServerAdminClient();
const result = await client.resources.create(body);
return NextResponse.json(result, { status: 201 });
} catch (error) {
return handleSDKError(error);
}
}export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') ?? '1');
const limit = parseInt(searchParams.get('limit') ?? '20');
const client = getServerAdminClient();
const data = await client.resources.list({ page, limit });
return NextResponse.json(data);
} catch (error) {
return handleSDKError(error);
}
}// app/api/resources/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const client = getServerAdminClient();
const resource = await client.resources.get(params.id);
return NextResponse.json(resource);
} catch (error) {
return handleSDKError(error);
}
}# Backend Authentication (Required)
CONDUIT_API_TO_API_BACKEND_AUTH_KEY=your-master-key-here
# API Endpoints (Internal - Docker service names)
CONDUIT_ADMIN_API_BASE_URL=http://admin-api:5002
CONDUIT_API_BASE_URL=http://core-api:5000
# API Endpoints (External - Browser-accessible URLs)
CONDUIT_ADMIN_API_EXTERNAL_URL=http://localhost:5002
CONDUIT_API_EXTERNAL_URL=http://localhost:5000
# Clerk Authentication (Production)
CLERK_AUTH_ENABLED=true # Set to false for development without auth
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...const isDevelopment = process.env.CLERK_AUTH_ENABLED !== 'true';
if (isDevelopment) {
// Development mode behavior
// - Master key used directly
// - Simplified authentication
// - More verbose logging
} else {
// Production mode behavior
// - Ephemeral keys generated via API
// - Full Clerk authentication
// - Minimal logging
}-
Use singleton getters for SDK clients
const adminClient = getServerAdminClient(); const coreClient = await getServerCoreClient(); // async!
-
Wrap all operations in try/catch
try { // ... operation } catch (error) { return handleSDKError(error); }
-
Validate input before SDK calls
if (!body.name?.trim()) { return NextResponse.json({ error: 'Name required' }, { status: 400 }); }
-
Use appropriate HTTP status codes
return NextResponse.json(created, { status: 201 }); // Created return new Response(null, { status: 204 }); // No content
-
Return NextResponse.json() directly
return NextResponse.json(data); // Simple, direct
-
Don't create SDK clients manually
// ❌ Bad const client = new ConduitAdminClient({ ... }); // ✅ Good const client = getServerAdminClient();
-
Don't forget to await async SDK client
// ❌ Bad const client = getServerCoreClient(); // Missing await! // ✅ Good const client = await getServerCoreClient();
-
Don't add per-route authentication checks
// ❌ Bad - middleware already handles this export async function GET(request: NextRequest) { const auth = await checkAuth(request); // ... } // ✅ Good - trust middleware export async function GET(request: NextRequest) { // User is already authenticated }
-
Don't expose master keys to client
// ❌ NEVER do this return NextResponse.json({ masterKey: process.env.CONDUIT_API_TO_API_BACKEND_AUTH_KEY }); // ✅ Good - use ephemeral keys const ephemeralKey = await coreClient.auth.generateEphemeralKey(...); return NextResponse.json({ ephemeralKey: ephemeralKey.key });
// Standard JSON response
return NextResponse.json(data);
// With status code
return NextResponse.json(created, { status: 201 });
// With custom headers
return NextResponse.json(data, {
headers: {
'Cache-Control': 'private, max-age=300',
}
});
// Error response
return NextResponse.json({ error: 'Not found' }, { status: 404 });
// No content
return new Response(null, { status: 204 });// JSON body
const body = await request.json() as MyType;
// Form data
const formData = await request.formData();
const file = formData.get('file') as File;
// Headers
const authHeader = request.headers.get('authorization');
const clientIP = request.headers.get('x-forwarded-for') ?? 'unknown';
// Query params
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') ?? '1');- Only 3 API routes - WebAdmin is not a traditional API backend
- Clerk handles authentication - No custom auth in routes
- Use SDK clients -
getServerAdminClient()andgetServerCoreClient() - Simple error handling -
handleSDKError()for all SDK errors - Ephemeral keys - Secure client-side API access
- Direct patterns - No complex wrappers or decorators
// Standard route template
import { NextRequest, NextResponse } from 'next/server';
import { handleSDKError } from '@/lib/errors/sdk-errors';
import { getServerAdminClient } from '@/lib/server/sdk-config';
export async function GET(request: NextRequest) {
try {
const client = getServerAdminClient();
const data = await client.someService.someMethod();
return NextResponse.json(data);
} catch (error) {
return handleSDKError(error);
}
}- Architecture Documentation - System architecture overview
- SDK Best Practices - SDK usage patterns
- Next.js Integration - WebAdmin SDK integration
- Error Handling - WebAdmin error patterns
Document Status: ✅ Accurate and Current Last Validated: 2025-11-08 Actual Route Count: 3 Helper Functions: 6 (all documented correctly)