/**
* Admin API Routes
*
* Routes:
* - GET /api/auth/me - Current user info
* - GET /api/admin/info - Server name, version
* - GET /api/admin/tools - MCP tools list (basic)
* - GET /api/admin/tools-metadata - Full tool metadata (defer_loading, category, tags)
* - POST /api/admin/tools/execute - Execute tool directly (bypass AI)
* - GET /api/admin/resources - MCP resources list
* - GET /api/admin/prompts - MCP prompts list
* - POST /api/admin/logout - Log out admin
* - GET /api/admin/chat/providers - List AI providers and models
* - POST /api/admin/chat - Send message, get streaming response
* - GET /api/admin/chat/:sessionId - Load chat session
* - GET /api/admin/chat/sessions - List admin's chat sessions
* - DELETE /api/admin/chat/:sessionId - Delete chat session
*
* User Management (better-auth Admin plugin):
* - GET /api/admin/users - List all users
* - POST /api/admin/users/:id/role - Set user role
* - POST /api/admin/users/:id/ban - Ban user
* - POST /api/admin/users/:id/unban - Unban user
* - POST /api/admin/users/:id/impersonate - Impersonate user
* - POST /api/admin/stop-impersonating - Stop impersonation
*
* API Key Management (better-auth apiKey plugin):
* - GET /api/admin/api-keys - List current user's API keys
* - POST /api/admin/api-keys - Create new API key
* - DELETE /api/admin/api-keys/:id - Delete (revoke) API key
*/
import { Hono } from 'hono';
import type { Env } from '../types';
import { requireAdmin, isAdmin, getSession, type AdminUser } from './middleware';
import { createAuth } from '../lib/auth';
import { createTokenManager } from '../lib/token-manager';
import { getAdminDashboard } from './ui';
import {
loadChatSession,
createChatSession,
handleChatMessage,
listChatSessions,
deleteChatSession,
executeMcpTool,
} from './chat';
import { PROVIDERS, DEFAULT_MODELS, TOOL_CAPABLE_MODELS } from '../lib/ai/providers';
import { fetchModels, PROVIDER_DISPLAY } from '../lib/ai/openrouter';
import { fetchWorkersAIModels, HARDCODED_FUNCTION_CALLING_MODELS } from '../lib/ai/cloudflare-models';
// Import metadata from modular sources (single source of truth)
import { getToolsMetadata } from '../tools';
import { getResourcesMetadata } from '../resources';
import { getPromptsMetadata } from '../prompts';
import { checkRateLimit, RATE_LIMITS } from '../lib/rate-limit';
// Server metadata - generated from environment variables
function getServerInfo(env: Env) {
return {
name: env.SERVER_DISPLAY_NAME || 'MCP Server',
version: '1.1.0',
description: env.SERVER_DESCRIPTION || 'An MCP server with OAuth authentication.',
};
}
// Create Hono app
const app = new Hono<{ Bindings: Env; Variables: { adminUser: AdminUser } }>();
// ===== PUBLIC ROUTES =====
/**
* GET /api/auth/me - Get current user info (no admin required)
*/
app.get('/api/auth/me', async (c) => {
const session = await getSession(c);
if (!session) {
return c.json({ authenticated: false }, 200);
}
const { user } = session;
const isAdminByRole = user.role === 'admin';
const isAdminByEmail = isAdmin(user.email, c.env.ADMIN_EMAILS);
return c.json({
authenticated: true,
isAdmin: isAdminByRole || isAdminByEmail,
user: {
id: user.id,
email: user.email,
name: user.name,
picture: user.image,
role: user.role,
},
});
});
/**
* POST /api/admin/logout - Log out via better-auth
*/
app.post('/api/admin/logout', async (c) => {
const auth = createAuth(c.env);
// Use better-auth signOut
const result = await auth.api.signOut({ headers: c.req.raw.headers });
return c.json({ success: result.success ?? true });
});
// ===== ADMIN ROUTES (require admin) =====
/**
* GET /api/admin/info - Server metadata
*/
app.get('/api/admin/info', requireAdmin, async (c) => {
return c.json(getServerInfo(c.env));
});
/**
* GET /api/admin/tools - List MCP tools (basic info)
*/
app.get('/api/admin/tools', requireAdmin, async (c) => {
// Return simplified tools list from modular source
const allTools = getToolsMetadata();
const tools = allTools.map(t => ({
name: t.name,
description: t.description,
}));
return c.json({ tools });
});
/**
* GET /api/admin/tools-metadata - List MCP tools with full metadata
* Includes defer_loading, category, tags, auth requirements for:
* - Anthropic tool search beta (85% token reduction)
* - Admin AI chat tool execution
* - Dashboard tool introspection
*/
app.get('/api/admin/tools-metadata', requireAdmin, async (c) => {
const { category, tag, defer_loading } = c.req.query();
const allTools = getToolsMetadata();
let tools = [...allTools];
// Filter by category
if (category) {
tools = tools.filter(t => t.category === category);
}
// Filter by tag
if (tag) {
tools = tools.filter(t => t.tags?.includes(tag));
}
// Filter by defer_loading
if (defer_loading !== undefined) {
const isDeferLoading = defer_loading === 'true';
tools = tools.filter(t => !!t.defer_loading === isDeferLoading);
}
return c.json({
tools,
count: tools.length,
categories: ['query', 'mutation', 'integration', 'utility'],
allTags: [...new Set(allTools.flatMap(t => t.tags || []))],
});
});
/**
* POST /api/admin/tools/execute - Execute an MCP tool directly (bypass AI)
* For testing tools without going through AI chat
*/
app.post('/api/admin/tools/execute', requireAdmin, async (c) => {
const env = c.env as Env;
const body = await c.req.json<{ name: string; args?: Record<string, unknown> }>();
if (!body.name) {
return c.json({ success: false, error: 'Tool name is required' }, 400);
}
const result = await executeMcpTool(env, body.name, body.args || {});
return c.json(result);
});
/**
* GET /api/admin/resources - List MCP resources
*/
app.get('/api/admin/resources', requireAdmin, async (c) => {
const resources = getResourcesMetadata();
return c.json({ resources });
});
/**
* GET /api/admin/prompts - List MCP prompts
*/
app.get('/api/admin/prompts', requireAdmin, async (c) => {
const prompts = getPromptsMetadata();
return c.json({ prompts });
});
// ===== AI CHAT ROUTES =====
/**
* GET /api/admin/chat/providers - List AI providers and models (static)
* @deprecated Use /api/admin/models for dynamic model list
*/
app.get('/api/admin/chat/providers', requireAdmin, async (c) => {
const providers = Object.entries(PROVIDERS).map(([id, info]) => ({
id,
...info,
defaultModel: DEFAULT_MODELS[id],
}));
return c.json({
providers,
toolCapableModels: TOOL_CAPABLE_MODELS,
});
});
/**
* GET /api/admin/models - Dynamic model list from OpenRouter
*
* Returns tool-capable, recent models from AI Gateway supported providers.
* Cached for 24 hours.
*
* Query params:
* - provider: Filter to specific provider
* - refresh: Force cache refresh (true/false)
*/
app.get('/api/admin/models', requireAdmin, async (c) => {
const { provider, refresh } = c.req.query();
const forceRefresh = refresh === 'true';
try {
const data = await fetchModels(c.env.OAUTH_KV, forceRefresh);
// Filter by provider if specified
let models = data.models;
if (provider) {
models = models.filter(m => m.gatewayProvider === provider);
}
// Get Workers AI function-calling models (dynamic with fallback)
// Try to fetch from Cloudflare API, fall back to hardcoded list
let workersAIModels: Array<{
id: string;
name: string;
provider: string;
gatewayProvider: string;
contextLength: number;
created: string;
pricing: { prompt: number; completion: number };
}>;
try {
const cfModels = await fetchWorkersAIModels(
c.env.OAUTH_KV,
c.env.CF_ACCOUNT_ID || '0460574641fdbb98159c98ebf593e2bd',
c.env.CF_API_TOKEN,
forceRefresh
);
// Use dynamic models if available, else fallback
const sourceModels = cfModels.models.length > 0
? cfModels.models
: HARDCODED_FUNCTION_CALLING_MODELS;
workersAIModels = sourceModels.map(m => ({
id: m.id,
name: m.name, // Clean name without emoji (UI can add indicator)
provider: 'cloudflare',
gatewayProvider: 'cloudflare',
contextLength: m.contextLength,
created: new Date().toISOString().split('T')[0],
pricing: m.pricing,
description: m.description || '',
}));
} catch {
// Fallback to hardcoded
workersAIModels = HARDCODED_FUNCTION_CALLING_MODELS.map(m => ({
id: m.id,
name: m.name,
provider: 'cloudflare',
gatewayProvider: 'cloudflare',
contextLength: m.contextLength,
created: new Date().toISOString().split('T')[0],
pricing: m.pricing,
description: m.description || '',
}));
}
// Include Workers AI unless filtering for a different provider
if (!provider || provider === 'cloudflare') {
models = [...workersAIModels, ...models];
}
// Get unique providers with display names
const providers = [...new Set(models.map(m => m.gatewayProvider))].map(p => ({
id: p,
name: PROVIDER_DISPLAY[p] || p,
}));
return c.json({
models,
providers,
cached: !forceRefresh,
cachedAt: data.cachedAt,
count: models.length,
});
} catch (error) {
// Fallback to static providers if OpenRouter fails
console.error('OpenRouter fetch failed:', error);
return c.json({
models: [],
providers: Object.entries(PROVIDERS).map(([id, info]) => ({
id,
name: info.name,
})),
error: 'Failed to fetch models, using fallback',
fallback: true,
});
}
});
/**
* GET /api/admin/chat/sessions - List admin's chat sessions
*/
app.get('/api/admin/chat/sessions', requireAdmin, async (c) => {
const adminUser = c.get('adminUser');
const sessions = await listChatSessions(c.env, adminUser.email);
return c.json({ sessions });
});
/**
* POST /api/admin/chat - Send message, get streaming response
*/
app.post('/api/admin/chat', requireAdmin, async (c) => {
const adminUser = c.get('adminUser');
// Rate limit: 30 requests per minute per admin
const rateLimit = await checkRateLimit(
c.env.OAUTH_KV,
`chat:${adminUser.email}`,
RATE_LIMITS.adminChat
);
if (!rateLimit.allowed) {
return c.json(
{ error: 'Rate limit exceeded. Please wait before sending more messages.' },
429,
{
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(rateLimit.resetAt),
'Retry-After': String(Math.ceil((rateLimit.resetAt - Date.now()) / 1000)),
}
);
}
const body = await c.req.json<{
sessionId?: string;
message: string;
provider?: string;
model?: string;
}>();
const MAX_MESSAGE_LENGTH = 100_000; // 100KB limit
if (!body.message?.trim()) {
return c.json({ error: 'Message is required' }, 400);
}
if (body.message.length > MAX_MESSAGE_LENGTH) {
return c.json({ error: `Message too long (max ${MAX_MESSAGE_LENGTH.toLocaleString()} characters)` }, 400);
}
// Load or create session
let session = body.sessionId
? await loadChatSession(c.env, body.sessionId, adminUser.email)
: null;
if (!session) {
session = await createChatSession(c.env, adminUser.email, body.provider, body.model);
}
// Update provider/model if specified
if (body.provider) session.provider = body.provider;
if (body.model) session.model = body.model;
// Handle chat message and return stream (tools executed via DO RPC)
const stream = await handleChatMessage(c.env, session, body.message);
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
});
/**
* GET /api/admin/chat/:sessionId - Load chat session
*/
app.get('/api/admin/chat/:sessionId', requireAdmin, async (c) => {
const sessionId = c.req.param('sessionId');
const adminUser = c.get('adminUser');
const session = await loadChatSession(c.env, sessionId, adminUser.email);
if (!session) {
return c.json({ error: 'Session not found' }, 404);
}
// Verify ownership
if (session.adminEmail !== adminUser.email) {
return c.json({ error: 'Unauthorized' }, 403);
}
return c.json({ session });
});
/**
* DELETE /api/admin/chat/:sessionId - Delete chat session
*/
app.delete('/api/admin/chat/:sessionId', requireAdmin, async (c) => {
const sessionId = c.req.param('sessionId');
const adminUser = c.get('adminUser');
const session = await loadChatSession(c.env, sessionId, adminUser.email);
if (!session) {
return c.json({ error: 'Session not found' }, 404);
}
// Verify ownership
if (session.adminEmail !== adminUser.email) {
return c.json({ error: 'Unauthorized' }, 403);
}
await deleteChatSession(c.env, sessionId);
return c.json({ success: true });
});
// ===== USER MANAGEMENT ROUTES (better-auth Admin plugin) =====
/**
* GET /api/admin/users - List all users
*/
app.get('/api/admin/users', requireAdmin, async (c) => {
const auth = createAuth(c.env);
const { limit, offset, search, sortBy, sortOrder } = c.req.query();
try {
const result = await auth.api.listUsers({
headers: c.req.raw.headers,
query: {
limit: limit ? parseInt(limit) : 50,
offset: offset ? parseInt(offset) : 0,
searchValue: search || undefined,
searchField: search ? 'email' : undefined,
sortBy: (sortBy as 'createdAt' | 'email' | 'name') || 'createdAt',
sortDirection: (sortOrder as 'asc' | 'desc') || 'desc',
},
});
return c.json(result);
} catch (error) {
console.error('Error listing users:', error);
return c.json({ error: 'Failed to list users' }, 500);
}
});
/**
* POST /api/admin/users/:id/role - Set user role
*/
app.post('/api/admin/users/:id/role', requireAdmin, async (c) => {
const userId = c.req.param('id');
const { role } = await c.req.json<{ role: 'admin' | 'user' }>();
const auth = createAuth(c.env);
// Validate role
if (role !== 'admin' && role !== 'user') {
return c.json({ error: 'Invalid role. Must be "admin" or "user"' }, 400);
}
try {
await auth.api.setRole({
headers: c.req.raw.headers,
body: { userId, role },
});
return c.json({ success: true, userId, role });
} catch (error) {
console.error('Error setting role:', error);
return c.json({ error: 'Failed to set role' }, 500);
}
});
/**
* POST /api/admin/users/:id/ban - Ban user
*/
app.post('/api/admin/users/:id/ban', requireAdmin, async (c) => {
const userId = c.req.param('id');
const { reason, expiresIn } = await c.req.json<{ reason?: string; expiresIn?: number }>();
const auth = createAuth(c.env);
try {
await auth.api.banUser({
headers: c.req.raw.headers,
body: {
userId,
banReason: reason,
banExpiresIn: expiresIn, // seconds until ban expires
},
});
return c.json({ success: true, userId, banned: true });
} catch (error) {
console.error('Error banning user:', error);
return c.json({ error: 'Failed to ban user' }, 500);
}
});
/**
* POST /api/admin/users/:id/unban - Unban user
*/
app.post('/api/admin/users/:id/unban', requireAdmin, async (c) => {
const userId = c.req.param('id');
const auth = createAuth(c.env);
try {
await auth.api.unbanUser({
headers: c.req.raw.headers,
body: { userId },
});
return c.json({ success: true, userId, banned: false });
} catch (error) {
console.error('Error unbanning user:', error);
return c.json({ error: 'Failed to unban user' }, 500);
}
});
/**
* POST /api/admin/users/:id/impersonate - Impersonate user
*/
app.post('/api/admin/users/:id/impersonate', requireAdmin, async (c) => {
const userId = c.req.param('id');
const auth = createAuth(c.env);
try {
const result = await auth.api.impersonateUser({
headers: c.req.raw.headers,
body: { userId },
});
return c.json({ success: true, impersonating: userId, session: result });
} catch (error) {
console.error('Error impersonating user:', error);
return c.json({ error: 'Failed to impersonate user' }, 500);
}
});
/**
* POST /api/admin/stop-impersonating - Stop impersonation
*/
app.post('/api/admin/stop-impersonating', requireAdmin, async (c) => {
const auth = createAuth(c.env);
try {
await auth.api.stopImpersonating({
headers: c.req.raw.headers,
});
return c.json({ success: true });
} catch (error) {
console.error('Error stopping impersonation:', error);
return c.json({ error: 'Failed to stop impersonation' }, 500);
}
});
// ===== API KEY MANAGEMENT ROUTES =====
/**
* GET /api/admin/api-keys - List current user's API keys
*/
app.get('/api/admin/api-keys', requireAdmin, async (c) => {
const auth = createAuth(c.env);
try {
const result = await auth.api.listApiKeys({
headers: c.req.raw.headers,
});
// Map to display format (use 'start' for visible key prefix)
const keys = (result || []).map((key) => ({
id: key.id,
name: key.name || 'Unnamed Key',
start: key.start || '****', // First few chars of key
prefix: key.prefix,
enabled: key.enabled,
expiresAt: key.expiresAt,
lastRequest: key.lastRequest,
createdAt: key.createdAt,
}));
return c.json({ keys });
} catch (error) {
console.error('Error listing API keys:', error);
return c.json({ error: 'Failed to list API keys' }, 500);
}
});
/**
* POST /api/admin/api-keys - Create new API key
*
* Body: { name?: string, expiresIn?: number }
* expiresIn is in seconds (e.g., 2592000 = 30 days)
*/
app.post('/api/admin/api-keys', requireAdmin, async (c) => {
const auth = createAuth(c.env);
const body = await c.req.json<{ name?: string; expiresIn?: number }>();
try {
// Get current user's session first
const session = await auth.api.getSession({ headers: c.req.raw.headers });
console.log('[API Keys] Session:', session?.user?.id, session?.user?.email);
if (!session?.user?.id) {
return c.json({ error: 'No valid session' }, 401);
}
console.log('[API Keys] Creating key for user:', session.user.id, 'name:', body.name);
const result = await auth.api.createApiKey({
body: {
name: body.name || 'Unnamed Key',
expiresIn: body.expiresIn, // optional, undefined = never expires
userId: session.user.id, // Required for server-side API calls
},
});
console.log('[API Keys] Create result:', JSON.stringify(result, null, 2));
// Return full key ONCE - it won't be shown again
return c.json({
key: {
id: result.id,
name: result.name,
key: result.key, // Full key - only shown once!
prefix: result.prefix,
expiresAt: result.expiresAt,
createdAt: result.createdAt,
},
message: 'Copy this API key now - it will not be shown again!',
}, 201);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[API Keys] Error creating API key:', errorMessage, error);
return c.json({ error: `Failed to create API key: ${errorMessage}` }, 500);
}
});
/**
* DELETE /api/admin/api-keys/:id - Delete (revoke) API key
*/
app.delete('/api/admin/api-keys/:id', requireAdmin, async (c) => {
const keyId = c.req.param('id');
const auth = createAuth(c.env);
try {
await auth.api.deleteApiKey({
headers: c.req.raw.headers,
body: { keyId },
});
return c.json({ success: true, keyId });
} catch (error) {
console.error('Error deleting API key:', error);
return c.json({ error: 'Failed to delete API key' }, 500);
}
});
// ===== CONNECTED ACCOUNTS (OAuth Token Manager) =====
/**
* GET /api/admin/connected-accounts - List current user's connected OAuth accounts
*
* Returns all provider connections (Google, Microsoft, GitHub) with aliases,
* display names, scopes, and expiry status.
*/
app.get('/api/admin/connected-accounts', requireAdmin, async (c) => {
const auth = createAuth(c.env);
try {
// Get current user's session
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user?.email) {
return c.json({ error: 'No valid session' }, 401);
}
// Get connected accounts from TokenManager
const tokens = createTokenManager(c.env);
const accounts = await tokens.list({ userId: session.user.email });
// Format for display
const formatted = accounts.map(acc => ({
provider: acc.provider,
alias: acc.alias,
displayName: acc.displayName,
scopes: acc.scopes,
connectedAt: acc.connectedAt,
expiresAt: acc.expiresAt,
isExpired: acc.expiresAt ? acc.expiresAt < Date.now() : false,
}));
return c.json({ accounts: formatted });
} catch (error) {
console.error('Error listing connected accounts:', error);
return c.json({ error: 'Failed to list connected accounts' }, 500);
}
});
/**
* DELETE /api/admin/connected-accounts/:provider/:alias - Disconnect an OAuth account
*/
app.delete('/api/admin/connected-accounts/:provider/:alias', requireAdmin, async (c) => {
const provider = c.req.param('provider');
const alias = c.req.param('alias') || 'default';
const auth = createAuth(c.env);
try {
// Get current user's session
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user?.email) {
return c.json({ error: 'No valid session' }, 401);
}
// Revoke the token
const tokens = createTokenManager(c.env);
await tokens.revoke({ userId: session.user.email, provider, alias });
return c.json({ success: true, provider, alias });
} catch (error) {
console.error('Error disconnecting account:', error);
return c.json({ error: 'Failed to disconnect account' }, 500);
}
});
/**
* GET /api/admin/users/:id/sessions - List user's sessions
*/
app.get('/api/admin/users/:id/sessions', requireAdmin, async (c) => {
const userId = c.req.param('id');
const auth = createAuth(c.env);
try {
const result = await auth.api.listUserSessions({
headers: c.req.raw.headers,
body: { userId },
});
return c.json(result);
} catch (error) {
console.error('Error listing user sessions:', error);
return c.json({ error: 'Failed to list sessions' }, 500);
}
});
/**
* DELETE /api/admin/users/:id/sessions - Revoke all user sessions
*/
app.delete('/api/admin/users/:id/sessions', requireAdmin, async (c) => {
const userId = c.req.param('id');
const auth = createAuth(c.env);
try {
await auth.api.revokeUserSessions({
headers: c.req.raw.headers,
body: { userId },
});
return c.json({ success: true, userId });
} catch (error) {
console.error('Error revoking sessions:', error);
return c.json({ error: 'Failed to revoke sessions' }, 500);
}
});
// ===== ADMIN DASHBOARD =====
/**
* GET /admin - Admin dashboard HTML
*/
app.get('/admin', async (c) => {
const session = await getSession(c);
// Not logged in - redirect to login
if (!session) {
return c.redirect('/login?callbackURL=/admin');
}
const { user } = session;
// Check admin access
const isAdminByRole = user.role === 'admin';
const isAdminByEmail = isAdmin(user.email, c.env.ADMIN_EMAILS);
if (!isAdminByRole && !isAdminByEmail) {
return c.html(`
<!DOCTYPE html>
<html>
<head><title>Access Denied</title></head>
<body style="font-family: system-ui; padding: 2rem; text-align: center;">
<h1>Access Denied</h1>
<p>You (${user.email}) are not an admin.</p>
<p><a href="/">Go home</a></p>
</body>
</html>
`, 403);
}
// Map better-auth user to AdminUser format for dashboard
const adminUser: AdminUser = {
id: user.id,
email: user.email,
name: user.name || user.email,
image: user.image,
role: user.role,
};
// Admin - show dashboard with security headers
// Chat panel is hidden by default - set ENABLE_ADMIN_CHAT=true to show
const enableChat = c.env.ENABLE_ADMIN_CHAT === 'true';
const serverUrl = c.env.BETTER_AUTH_URL || new URL(c.req.url).origin;
const html = getAdminDashboard(adminUser, getServerInfo(c.env), {
enableChat,
connection: {
serverUrl,
serverName: 'google-calendar',
},
});
return new Response(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
// CSP: Allow inline scripts/styles (needed for current implementation)
// img-src allows profile pictures from Google, GitHub, Microsoft and data URIs
'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' https://*.googleusercontent.com https://avatars.githubusercontent.com https://graph.microsoft.com data:; connect-src 'self'; frame-ancestors 'none'",
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
},
});
});
export { app as adminApp, getServerInfo };