From a3f1f3935edff2a155b4e4bc4eea40e855ba9b97 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Tue, 13 Jan 2026 04:45:52 +1000 Subject: [PATCH] feat: add API key authentication system and update documentation API Key System: - Add TpmjsApiKey, ApiUsageRecord, ApiUsageSummary models to schema - Create API key utilities (generate, hash, mask with tpmjs_sk_ prefix) - Implement dual auth middleware (session + API key) - Add rate limiting with Vercel KV - Create CRUD endpoints for API key management - Add usage tracking and analytics endpoint - Build API key management UI in dashboard - Build usage dashboard with charts Route Protection: - Require auth for MCP endpoints (mcp:execute scope) - Require auth for agent chat (agent:chat scope) - Require auth for bridge connections (bridge:connect scope) Documentation Updates: - Update all curl/fetch examples with Authorization header - Document API key format, scopes, and rate limits - Update PRD-MCP-BRIDGE.md, MCP-AGGREGATOR-DESIGN.md - Update API docs page with auth requirements - Update HOW_TO_PUBLISH_A_TOOL.md --- HOW_TO_PUBLISH_A_TOOL.md | 13 +- .../conversation/[conversationId]/route.ts | 61 ++- apps/web/src/app/api/bridge/route.ts | 131 ++++-- .../[username]/[slug]/[transport]/route.ts | 117 ++++- .../user/tpmjs-api-keys/[id]/rotate/route.ts | 77 +++ .../app/api/user/tpmjs-api-keys/[id]/route.ts | 222 +++++++++ .../src/app/api/user/tpmjs-api-keys/route.ts | 152 ++++++ apps/web/src/app/api/user/usage/route.ts | 212 +++++++++ .../settings/tpmjs-api-keys/page.tsx | 437 ++++++++++++++++++ apps/web/src/app/dashboard/usage/page.tsx | 349 ++++++++++++++ apps/web/src/app/docs/api/page.tsx | 79 +++- .../components/dashboard/DashboardLayout.tsx | 4 +- apps/web/src/lib/api-keys/index.ts | 121 +++++ apps/web/src/lib/api-keys/middleware.ts | 225 +++++++++ apps/web/src/lib/api-keys/rate-limit.ts | 208 +++++++++ apps/web/src/lib/api-keys/usage.ts | 272 +++++++++++ docs/MCP-AGGREGATOR-DESIGN.md | 39 +- docs/PRD-MCP-BRIDGE.md | 37 +- docs/TOOL_HEALTH_SYSTEM.md | 8 +- packages/db/prisma/schema.prisma | 139 ++++++ 20 files changed, 2813 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/app/api/user/tpmjs-api-keys/[id]/rotate/route.ts create mode 100644 apps/web/src/app/api/user/tpmjs-api-keys/[id]/route.ts create mode 100644 apps/web/src/app/api/user/tpmjs-api-keys/route.ts create mode 100644 apps/web/src/app/api/user/usage/route.ts create mode 100644 apps/web/src/app/dashboard/settings/tpmjs-api-keys/page.tsx create mode 100644 apps/web/src/app/dashboard/usage/page.tsx create mode 100644 apps/web/src/lib/api-keys/index.ts create mode 100644 apps/web/src/lib/api-keys/middleware.ts create mode 100644 apps/web/src/lib/api-keys/rate-limit.ts create mode 100644 apps/web/src/lib/api-keys/usage.ts diff --git a/HOW_TO_PUBLISH_A_TOOL.md b/HOW_TO_PUBLISH_A_TOOL.md index 9ef0fc4..6804aeb 100644 --- a/HOW_TO_PUBLISH_A_TOOL.md +++ b/HOW_TO_PUBLISH_A_TOOL.md @@ -217,7 +217,11 @@ Your tool will be automatically discovered through: After publishing, your tool should appear on https://tpmjs.com within 15 minutes! -You can verify by searching: https://tpmjs.com/api/tools?q=yourpackagename +You can verify by searching (requires API key): +```bash +curl "https://tpmjs.com/api/tools?q=yourpackagename" \ + -H "Authorization: Bearer tpmjs_sk_your_api_key_here" +``` ## Real Example: @tpmjs/createblogpost @@ -411,14 +415,15 @@ Or manually check the structure matches the examples above. - Add all Rich tier fields for maximum visibility **Want to force a sync?** -You can manually trigger a sync (requires auth): +You can manually trigger a sync (requires CRON_SECRET, not a user API key): ```bash curl -X POST "https://tpmjs.com/api/sync/keyword" \ - -H "Authorization: Bearer YOUR_CRON_SECRET" + -H "Authorization: Bearer $CRON_SECRET" ``` ## Support Questions or issues? - File an issue: https://github.com/ajaxdavis/tpmjs/issues -- Check the API: https://tpmjs.com/api/tools +- Check the API docs: https://tpmjs.com/docs/api +- Generate an API key: https://tpmjs.com/dashboard/settings/tpmjs-api-keys diff --git a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts index 89cc1b7..6b4b448 100644 --- a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts +++ b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts @@ -5,7 +5,10 @@ * GET: Retrieve conversation history * DELETE: Delete a conversation * - * This endpoint uses agent id directly for dashboard usage + * This endpoint uses agent id directly for dashboard usage. + * + * Authentication: Supports both session auth and TPMJS API key auth. + * Requires 'agent:chat' scope for API key access. */ import { Prisma, prisma } from '@tpmjs/db'; @@ -14,6 +17,8 @@ import { SendMessageSchema } from '@tpmjs/types/agent'; import type { LanguageModel, ModelMessage } from 'ai'; import { type NextRequest, NextResponse } from 'next/server'; import { decryptApiKey } from '@/lib/crypto/api-keys'; +import { authenticateRequest, hasScope } from '~/lib/api-keys/middleware'; +import { trackUsage } from '~/lib/api-keys/usage'; import { checkRateLimit, type RateLimitConfig } from '~/lib/rate-limit'; /** @@ -72,7 +77,26 @@ async function getProviderModel( * Send a message and stream the AI response via SSE */ export async function POST(request: NextRequest, context: RouteContext): Promise { - // Check rate limit first to prevent expensive LLM calls + const startTime = Date.now(); + + // Authenticate request (supports both session and API key) + const authResult = await authenticateRequest(); + + if (!authResult.authenticated) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check scope for API key auth + if (authResult.authenticated && !authResult.isSessionAuth) { + if (!hasScope(authResult, 'agent:chat')) { + return NextResponse.json( + { error: 'API key does not have agent:chat scope' }, + { status: 403 } + ); + } + } + + // Check rate limit (after auth so we can track by user if needed) const rateLimitResponse = checkRateLimit(request, CHAT_RATE_LIMIT); if (rateLimitResponse) { return rateLimitResponse; @@ -439,6 +463,23 @@ export async function POST(request: NextRequest, context: RouteContext): Promise conversationId: conversation.id, executionTimeMs, }); + + // Track usage + if (authResult.userId) { + trackUsage({ + apiKeyId: authResult.apiKeyId ?? undefined, + userId: authResult.userId, + endpoint: `/api/agents/${agentId}/conversation/${conversationId}`, + method: 'POST', + statusCode: 200, + latencyMs: executionTimeMs, + resourceType: 'agent', + resourceId: agentId, + tokensIn: inputTokens, + tokensOut: outputTokens, + model: agent.modelId, + }); + } } catch (error) { // Log detailed error for debugging console.error('[Agent] Conversation stream error:', { @@ -450,6 +491,22 @@ export async function POST(request: NextRequest, context: RouteContext): Promise sendEvent('error', { message: error instanceof Error ? error.message : 'Unknown error', }); + + // Track error + if (authResult.userId) { + trackUsage({ + apiKeyId: authResult.apiKeyId ?? undefined, + userId: authResult.userId, + endpoint: `/api/agents/${agentId}/conversation/${conversationId}`, + method: 'POST', + statusCode: 500, + latencyMs: Date.now() - startTime, + resourceType: 'agent', + resourceId: agentId, + errorCode: 'STREAM_ERROR', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }); + } } finally { controller.close(); } diff --git a/apps/web/src/app/api/bridge/route.ts b/apps/web/src/app/api/bridge/route.ts index 8ddfaf4..fcedb92 100644 --- a/apps/web/src/app/api/bridge/route.ts +++ b/apps/web/src/app/api/bridge/route.ts @@ -1,8 +1,11 @@ import { prisma } from '@tpmjs/db'; import { type NextRequest, NextResponse } from 'next/server'; +import { authenticateRequest, hasScope } from '~/lib/api-keys/middleware'; +import { trackUsage } from '~/lib/api-keys/usage'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +export const maxDuration = 60; /** * Bridge Registration & Status API @@ -10,30 +13,37 @@ export const dynamic = 'force-dynamic'; * POST: Register bridge tools * GET: Get bridge status and pending tool calls * DELETE: Disconnect bridge + * + * Authentication: Supports both session auth and TPMJS API key auth. + * Requires 'bridge:connect' scope for API key access. */ -// Validate API key and get user -async function validateApiKey(token: string | null | undefined) { - if (!token) return null; - - // For now, use session-based auth - // In production, you'd want proper API key validation with encrypted keys - const session = await prisma.session.findUnique({ - where: { token }, - include: { user: true }, - }); - - return session?.user || null; -} - // POST: Register bridge and its tools export async function POST(request: NextRequest) { - try { - const authHeader = request.headers.get('authorization'); - const token = authHeader?.replace('Bearer ', ''); + const startTime = Date.now(); + let authResult: Awaited> | null = null; - const user = await validateApiKey(token); - if (!user) { + try { + // Authenticate request (supports both session and API key) + authResult = await authenticateRequest(); + + if (!authResult.authenticated) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check scope for API key auth + if (authResult.authenticated && !authResult.isSessionAuth) { + if (!hasScope(authResult, 'bridge:connect')) { + return NextResponse.json( + { error: 'API key does not have bridge:connect scope' }, + { status: 403 } + ); + } + } + + // Get user for bridge operations + const userId = authResult.userId; + if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -43,7 +53,7 @@ export async function POST(request: NextRequest) { if (type === 'register') { // Register bridge and tools await prisma.bridgeConnection.upsert({ - where: { userId: user.id }, + where: { userId }, update: { status: 'connected', tools: tools || [], @@ -52,7 +62,7 @@ export async function POST(request: NextRequest) { clientOS: body.clientOS, }, create: { - userId: user.id, + userId, status: 'connected', tools: tools || [], lastSeen: new Date(), @@ -61,6 +71,17 @@ export async function POST(request: NextRequest) { }, }); + // Track usage + trackUsage({ + apiKeyId: authResult?.apiKeyId ?? undefined, + userId, + endpoint: '/api/bridge', + method: 'POST', + statusCode: 200, + latencyMs: Date.now() - startTime, + resourceType: 'bridge', + }); + return NextResponse.json({ success: true, message: `Registered ${tools?.length || 0} tools`, @@ -80,7 +101,7 @@ export async function POST(request: NextRequest) { if (type === 'heartbeat') { // Update last seen await prisma.bridgeConnection.update({ - where: { userId: user.id }, + where: { userId }, data: { lastSeen: new Date() }, }); @@ -90,6 +111,22 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid type' }, { status: 400 }); } catch (error) { console.error('Bridge POST error:', error); + + // Track error + if (authResult?.userId) { + trackUsage({ + apiKeyId: authResult?.apiKeyId ?? undefined, + userId: authResult.userId, + endpoint: '/api/bridge', + method: 'POST', + statusCode: 500, + latencyMs: Date.now() - startTime, + resourceType: 'bridge', + errorCode: 'INTERNAL_ERROR', + errorMessage: error instanceof Error ? error.message : 'Internal error', + }); + } + return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal error' }, { status: 500 } @@ -98,19 +135,31 @@ export async function POST(request: NextRequest) { } // GET: Get pending tool calls (polling) -export async function GET(request: NextRequest) { +export async function GET(_request: NextRequest) { try { - const authHeader = request.headers.get('authorization'); - const token = authHeader?.replace('Bearer ', ''); + // Authenticate request (supports both session and API key) + const authResult = await authenticateRequest(); - const user = await validateApiKey(token); - if (!user) { + if (!authResult.authenticated) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check scope for API key auth + if (!authResult.isSessionAuth && !hasScope(authResult, 'bridge:connect')) { + return NextResponse.json( + { error: 'API key does not have bridge:connect scope' }, + { status: 403 } + ); + } + + const userId = authResult.userId; + if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } // Get pending tool calls for this user const pendingCalls = Array.from(pendingToolCalls.entries()) - .filter(([key]) => key.startsWith(`${user.id}:`)) + .filter(([key]) => key.startsWith(`${userId}:`)) .map(([key, value]) => { pendingToolCalls.delete(key); // Remove after returning return value; @@ -118,7 +167,7 @@ export async function GET(request: NextRequest) { // Update last seen await prisma.bridgeConnection.update({ - where: { userId: user.id }, + where: { userId }, data: { lastSeen: new Date() }, }); @@ -136,18 +185,30 @@ export async function GET(request: NextRequest) { } // DELETE: Disconnect bridge -export async function DELETE(request: NextRequest) { +export async function DELETE(_request: NextRequest) { try { - const authHeader = request.headers.get('authorization'); - const token = authHeader?.replace('Bearer ', ''); + // Authenticate request (supports both session and API key) + const authResult = await authenticateRequest(); - const user = await validateApiKey(token); - if (!user) { + if (!authResult.authenticated) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check scope for API key auth + if (!authResult.isSessionAuth && !hasScope(authResult, 'bridge:connect')) { + return NextResponse.json( + { error: 'API key does not have bridge:connect scope' }, + { status: 403 } + ); + } + + const userId = authResult.userId; + if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } await prisma.bridgeConnection.update({ - where: { userId: user.id }, + where: { userId }, data: { status: 'disconnected' }, }); diff --git a/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts b/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts index 8db74fb..05bbeb8 100644 --- a/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts +++ b/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts @@ -1,6 +1,14 @@ import { prisma } from '@tpmjs/db'; import { type NextRequest, NextResponse } from 'next/server'; +import { API_KEY_SCOPES } from '~/lib/api-keys'; +import { authenticateRequest, getClientMetadata, hasScope } from '~/lib/api-keys/middleware'; +import { + checkApiKeyRateLimit, + createRateLimitResponse, + getRateLimitHeaders, +} from '~/lib/api-keys/rate-limit'; +import { trackUsage } from '~/lib/api-keys/usage'; import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/handlers'; export const runtime = 'nodejs'; @@ -9,6 +17,9 @@ export const maxDuration = 60; const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries +// Authentication is required for all MCP operations +const REQUIRE_AUTH = true; + interface RouteContext { params: Promise<{ username: string; slug: string; transport: string }>; } @@ -205,6 +216,9 @@ function handleSseGet( * MCP JSON-RPC endpoint for tool execution */ export async function POST(request: NextRequest, context: RouteContext): Promise { + const startTime = Date.now(); + let authResult: Awaited> | null = null; + try { const { username, slug, transport } = await context.params; @@ -219,6 +233,53 @@ export async function POST(request: NextRequest, context: RouteContext): Promise ); } + // Authenticate the request + authResult = await authenticateRequest(); + + // Check if auth is required + if (REQUIRE_AUTH && !authResult.authenticated) { + return NextResponse.json( + { + jsonrpc: '2.0', + error: { code: -32000, message: authResult.error || 'Authentication required' }, + id: null, + }, + { status: 401 } + ); + } + + // Check scope if authenticated + if (authResult.authenticated && !hasScope(authResult, API_KEY_SCOPES.MCP_EXECUTE)) { + return NextResponse.json( + { + jsonrpc: '2.0', + error: { code: -32000, message: 'Missing required scope: mcp:execute' }, + id: null, + }, + { status: 403 } + ); + } + + // Rate limit if authenticated via API key + if (authResult.authenticated && authResult.apiKeyId) { + const rateLimitResult = await checkApiKeyRateLimit( + authResult.apiKeyId, + authResult.tier || 'FREE' + ); + + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + } + + // Log warning for unauthenticated requests (soft launch) + if (!authResult.authenticated && !REQUIRE_AUTH) { + console.warn( + `[MCP] Unauthenticated request to /${username}/${slug}/${transport} - ` + + 'API key authentication will be required in a future update' + ); + } + const collection = await getPublicCollectionByUsernameAndSlug(username, slug); if (!collection) { @@ -228,14 +289,66 @@ export async function POST(request: NextRequest, context: RouteContext): Promise ); } + let response: Response; if (transport === 'sse') { - return handleSseTransport(request, collection.id, collection.name); + response = await handleSseTransport(request, collection.id, collection.name); + } else { + response = await handleHttpTransport(request, collection.id, collection.name); } - return handleHttpTransport(request, collection.id, collection.name); + // Track usage for authenticated requests + if (authResult.authenticated && authResult.userId) { + const clientMeta = await getClientMetadata(); + trackUsage({ + apiKeyId: authResult.apiKeyId, + userId: authResult.userId, + endpoint: `/api/mcp/${username}/${slug}/${transport}`, + method: 'POST', + statusCode: response.status, + latencyMs: Date.now() - startTime, + resourceType: 'mcp', + resourceId: collection.id, + userAgent: clientMeta.userAgent, + ipAddress: clientMeta.ipAddress, + }); + } + + // Add rate limit headers for authenticated requests + if (authResult.authenticated && authResult.apiKeyId) { + const rateLimitResult = await checkApiKeyRateLimit( + authResult.apiKeyId, + authResult.tier || 'FREE' + ); + const headers = getRateLimitHeaders(rateLimitResult); + for (const [key, value] of Object.entries(headers)) { + response.headers.set(key, value); + } + } + + return response; } catch (error) { console.error('[MCP POST] Error:', error); const message = error instanceof Error ? error.message : 'Internal server error'; + + // Track error for authenticated requests + if (authResult?.authenticated && authResult.userId) { + const { username, slug, transport } = await context.params; + const clientMeta = await getClientMetadata(); + trackUsage({ + apiKeyId: authResult.apiKeyId, + userId: authResult.userId, + endpoint: `/api/mcp/${username}/${slug}/${transport}`, + method: 'POST', + statusCode: 500, + latencyMs: Date.now() - startTime, + resourceType: 'mcp', + errorCode: 'INTERNAL_ERROR', + errorMessage: message, + userAgent: clientMeta.userAgent, + ipAddress: clientMeta.ipAddress, + }); + } + return NextResponse.json( { jsonrpc: '2.0', error: { code: -32603, message }, id: null }, { status: 500 } diff --git a/apps/web/src/app/api/user/tpmjs-api-keys/[id]/rotate/route.ts b/apps/web/src/app/api/user/tpmjs-api-keys/[id]/rotate/route.ts new file mode 100644 index 0000000..0f1e2d2 --- /dev/null +++ b/apps/web/src/app/api/user/tpmjs-api-keys/[id]/rotate/route.ts @@ -0,0 +1,77 @@ +import { prisma } from '@tpmjs/db'; +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; +import { generateApiKey } from '~/lib/api-keys'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +/** + * POST /api/user/tpmjs-api-keys/[id]/rotate + * + * Rotate an API key - generates a new key while keeping the same ID, + * name, scopes, and settings. The old key is immediately invalidated. + * + * Returns the new raw key - it will not be shown again! + */ +export async function POST(_request: Request, { params }: RouteParams) { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + // Verify ownership + const existing = await prisma.tpmjsApiKey.findFirst({ + where: { id, userId: session.user.id }, + }); + + if (!existing) { + return NextResponse.json({ error: 'API key not found' }, { status: 404 }); + } + + // Generate new key + const { rawKey, keyHash, keyPrefix } = generateApiKey(); + + // Update the key with new hash and prefix + const apiKey = await prisma.tpmjsApiKey.update({ + where: { id }, + data: { + keyHash, + keyPrefix, + // Reset lastUsedAt since it's a new key + lastUsedAt: null, + }, + select: { + id: true, + name: true, + keyPrefix: true, + scopes: true, + rateLimit: true, + isActive: true, + expiresAt: true, + createdAt: true, + updatedAt: true, + }, + }); + + return NextResponse.json({ + success: true, + apiKey: { + ...apiKey, + key: rawKey, // IMPORTANT: Only shown once! + }, + message: 'API key rotated. Copy the new key now - it will not be shown again!', + }); + } catch (error) { + console.error('[API Keys] Error rotating key:', error); + return NextResponse.json({ error: 'Failed to rotate API key' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/user/tpmjs-api-keys/[id]/route.ts b/apps/web/src/app/api/user/tpmjs-api-keys/[id]/route.ts new file mode 100644 index 0000000..4e08135 --- /dev/null +++ b/apps/web/src/app/api/user/tpmjs-api-keys/[id]/route.ts @@ -0,0 +1,222 @@ +import { prisma } from '@tpmjs/db'; +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; +import { maskApiKey } from '~/lib/api-keys'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +/** + * GET /api/user/tpmjs-api-keys/[id] + * + * Get details for a specific API key. + */ +export async function GET(_request: Request, { params }: RouteParams) { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + const apiKey = await prisma.tpmjsApiKey.findFirst({ + where: { + id, + userId: session.user.id, + }, + select: { + id: true, + name: true, + keyPrefix: true, + scopes: true, + rateLimit: true, + isActive: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + usageRecords: true, + }, + }, + }, + }); + + if (!apiKey) { + return NextResponse.json({ error: 'API key not found' }, { status: 404 }); + } + + return NextResponse.json({ + success: true, + apiKey: { + ...apiKey, + maskedKey: maskApiKey(apiKey.keyPrefix), + usageRecordCount: apiKey._count.usageRecords, + }, + }); + } catch (error) { + console.error('[API Keys] Error getting key:', error); + return NextResponse.json({ error: 'Failed to get API key' }, { status: 500 }); + } +} + +/** + * PATCH /api/user/tpmjs-api-keys/[id] + * + * Update an API key (name, scopes, isActive, expiresAt). + * + * Request body: + * { + * name?: string; + * scopes?: string[]; + * isActive?: boolean; + * expiresAt?: string | null; + * } + */ +export async function PATCH(request: Request, { params }: RouteParams) { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const { name, scopes, isActive, expiresAt } = body; + + // Verify ownership + const existing = await prisma.tpmjsApiKey.findFirst({ + where: { id, userId: session.user.id }, + }); + + if (!existing) { + return NextResponse.json({ error: 'API key not found' }, { status: 404 }); + } + + // Build update data + const updateData: { + name?: string; + scopes?: string[]; + isActive?: boolean; + expiresAt?: Date | null; + } = {}; + + if (name !== undefined) { + if (typeof name !== 'string' || name.trim().length === 0) { + return NextResponse.json({ error: 'Name cannot be empty' }, { status: 400 }); + } + if (name.length > 100) { + return NextResponse.json({ error: 'Name must be 100 characters or less' }, { status: 400 }); + } + updateData.name = name.trim(); + } + + if (scopes !== undefined) { + if (!Array.isArray(scopes)) { + return NextResponse.json({ error: 'Scopes must be an array' }, { status: 400 }); + } + updateData.scopes = scopes; + } + + if (isActive !== undefined) { + if (typeof isActive !== 'boolean') { + return NextResponse.json({ error: 'isActive must be a boolean' }, { status: 400 }); + } + updateData.isActive = isActive; + } + + if (expiresAt !== undefined) { + if (expiresAt === null) { + updateData.expiresAt = null; + } else { + const date = new Date(expiresAt); + if (Number.isNaN(date.getTime())) { + return NextResponse.json({ error: 'Invalid expiration date' }, { status: 400 }); + } + if (date <= new Date()) { + return NextResponse.json( + { error: 'Expiration date must be in the future' }, + { status: 400 } + ); + } + updateData.expiresAt = date; + } + } + + if (Object.keys(updateData).length === 0) { + return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 }); + } + + const apiKey = await prisma.tpmjsApiKey.update({ + where: { id }, + data: updateData, + select: { + id: true, + name: true, + keyPrefix: true, + scopes: true, + rateLimit: true, + isActive: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + updatedAt: true, + }, + }); + + return NextResponse.json({ + success: true, + apiKey: { + ...apiKey, + maskedKey: maskApiKey(apiKey.keyPrefix), + }, + }); + } catch (error) { + console.error('[API Keys] Error updating key:', error); + return NextResponse.json({ error: 'Failed to update API key' }, { status: 500 }); + } +} + +/** + * DELETE /api/user/tpmjs-api-keys/[id] + * + * Delete an API key. This is permanent and cannot be undone. + */ +export async function DELETE(_request: Request, { params }: RouteParams) { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + // Verify ownership + const existing = await prisma.tpmjsApiKey.findFirst({ + where: { id, userId: session.user.id }, + }); + + if (!existing) { + return NextResponse.json({ error: 'API key not found' }, { status: 404 }); + } + + await prisma.tpmjsApiKey.delete({ + where: { id }, + }); + + return NextResponse.json({ + success: true, + message: 'API key deleted successfully', + }); + } catch (error) { + console.error('[API Keys] Error deleting key:', error); + return NextResponse.json({ error: 'Failed to delete API key' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/user/tpmjs-api-keys/route.ts b/apps/web/src/app/api/user/tpmjs-api-keys/route.ts new file mode 100644 index 0000000..49993a8 --- /dev/null +++ b/apps/web/src/app/api/user/tpmjs-api-keys/route.ts @@ -0,0 +1,152 @@ +import { prisma } from '@tpmjs/db'; +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; +import { + type ApiKeyScope, + DEFAULT_API_KEY_SCOPES, + generateApiKey, + maskApiKey, +} from '~/lib/api-keys'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/user/tpmjs-api-keys + * + * List all API keys for the authenticated user. + * Requires session auth (not API key auth) for security. + */ +export async function GET() { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const apiKeys = await prisma.tpmjsApiKey.findMany({ + where: { userId: session.user.id }, + select: { + id: true, + name: true, + keyPrefix: true, + scopes: true, + rateLimit: true, + isActive: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json({ + success: true, + apiKeys: apiKeys.map((key) => ({ + ...key, + maskedKey: maskApiKey(key.keyPrefix), + })), + }); + } catch (error) { + console.error('[API Keys] Error listing keys:', error); + return NextResponse.json({ error: 'Failed to list API keys' }, { status: 500 }); + } +} + +/** + * POST /api/user/tpmjs-api-keys + * + * Create a new API key for the authenticated user. + * Returns the raw key ONLY ONCE - it cannot be retrieved again. + * + * Request body: + * { + * name: string; // Required: User-friendly name + * scopes?: string[]; // Optional: Permissions (defaults to all) + * expiresAt?: string; // Optional: ISO date string for expiration + * } + */ +export async function POST(request: Request) { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { name, scopes, expiresAt } = body; + + // Validate name + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }); + } + + if (name.length > 100) { + return NextResponse.json({ error: 'Name must be 100 characters or less' }, { status: 400 }); + } + + // Validate scopes + const validScopes: ApiKeyScope[] = scopes?.length > 0 ? scopes : DEFAULT_API_KEY_SCOPES; + + // Validate expiration + let expiresAtDate: Date | undefined; + if (expiresAt) { + expiresAtDate = new Date(expiresAt); + if (Number.isNaN(expiresAtDate.getTime())) { + return NextResponse.json({ error: 'Invalid expiration date' }, { status: 400 }); + } + if (expiresAtDate <= new Date()) { + return NextResponse.json( + { error: 'Expiration date must be in the future' }, + { status: 400 } + ); + } + } + + // Check key limit (max 10 keys per user) + const existingKeyCount = await prisma.tpmjsApiKey.count({ + where: { userId: session.user.id }, + }); + + if (existingKeyCount >= 10) { + return NextResponse.json( + { error: 'Maximum of 10 API keys allowed. Please delete an existing key first.' }, + { status: 400 } + ); + } + + // Generate the key + const { rawKey, keyHash, keyPrefix } = generateApiKey(); + + // Create the key in database + const apiKey = await prisma.tpmjsApiKey.create({ + data: { + userId: session.user.id, + name: name.trim(), + keyHash, + keyPrefix, + scopes: validScopes, + expiresAt: expiresAtDate, + }, + }); + + // Return the raw key - ONLY TIME it's shown! + return NextResponse.json({ + success: true, + apiKey: { + id: apiKey.id, + name: apiKey.name, + key: rawKey, // IMPORTANT: Only shown once! + keyPrefix: apiKey.keyPrefix, + scopes: apiKey.scopes, + expiresAt: apiKey.expiresAt, + createdAt: apiKey.createdAt, + }, + message: 'API key created. Copy the key now - it will not be shown again!', + }); + } catch (error) { + console.error('[API Keys] Error creating key:', error); + return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/user/usage/route.ts b/apps/web/src/app/api/user/usage/route.ts new file mode 100644 index 0000000..daa0f86 --- /dev/null +++ b/apps/web/src/app/api/user/usage/route.ts @@ -0,0 +1,212 @@ +import { prisma } from '@tpmjs/db'; +import { headers } from 'next/headers'; +import { type NextRequest, NextResponse } from 'next/server'; +import { API_KEY_SCOPES } from '~/lib/api-keys'; +import { authenticateRequest, hasScope } from '~/lib/api-keys/middleware'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/user/usage + * + * Get usage analytics for the authenticated user. + * Supports both session auth and API key auth (with usage:read scope). + * + * Query parameters: + * - period: 'hourly' | 'daily' | 'monthly' (default: 'daily') + * - start: ISO date string (default: 30 days ago) + * - end: ISO date string (default: now) + * - apiKeyId: Optional filter by specific API key + */ +export async function GET(request: NextRequest) { + try { + // Try API key auth first (for programmatic access) + const apiKeyAuth = await authenticateRequest(); + + let userId: string; + + if (apiKeyAuth.authenticated) { + // Check scope for API key auth + if (!hasScope(apiKeyAuth, API_KEY_SCOPES.USAGE_READ)) { + return NextResponse.json({ error: 'Missing required scope: usage:read' }, { status: 403 }); + } + userId = apiKeyAuth.userId!; + } else { + // Fall back to session auth + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + userId = session.user.id; + } + + // Parse query parameters + const { searchParams } = new URL(request.url); + const period = (searchParams.get('period') || 'daily') as 'hourly' | 'daily' | 'monthly'; + const apiKeyId = searchParams.get('apiKeyId'); + + // Parse date range + const now = new Date(); + const defaultStart = new Date(); + defaultStart.setDate(defaultStart.getDate() - 30); + + const start = searchParams.get('start') ? new Date(searchParams.get('start')!) : defaultStart; + const end = searchParams.get('end') ? new Date(searchParams.get('end')!) : now; + + // Validate dates + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return NextResponse.json({ error: 'Invalid date format' }, { status: 400 }); + } + + if (start >= end) { + return NextResponse.json({ error: 'Start date must be before end date' }, { status: 400 }); + } + + // Validate period + if (!['hourly', 'daily', 'monthly'].includes(period)) { + return NextResponse.json( + { error: 'Invalid period. Use: hourly, daily, monthly' }, + { status: 400 } + ); + } + + // If apiKeyId is specified, verify ownership + if (apiKeyId) { + const key = await prisma.tpmjsApiKey.findFirst({ + where: { id: apiKeyId, userId }, + }); + if (!key) { + return NextResponse.json({ error: 'API key not found' }, { status: 404 }); + } + } + + // Fetch usage summaries + const summaries = await prisma.apiUsageSummary.findMany({ + where: { + userId, + periodType: period, + periodStart: { + gte: start, + lte: end, + }, + ...(apiKeyId ? { apiKeyId } : {}), + }, + orderBy: { periodStart: 'asc' }, + }); + + // Calculate totals + const totals = summaries.reduce( + (acc, summary) => ({ + totalRequests: acc.totalRequests + summary.totalRequests, + successRequests: acc.successRequests + summary.successRequests, + errorRequests: acc.errorRequests + summary.errorRequests, + totalTokensIn: acc.totalTokensIn + summary.totalTokensIn, + totalTokensOut: acc.totalTokensOut + summary.totalTokensOut, + estimatedCostCents: acc.estimatedCostCents + summary.estimatedCostCents, + }), + { + totalRequests: 0, + successRequests: 0, + errorRequests: 0, + totalTokensIn: 0, + totalTokensOut: 0, + estimatedCostCents: 0, + } + ); + + // Aggregate endpoint counts across all summaries + const endpointCounts: Record = {}; + for (const summary of summaries) { + const counts = summary.endpointCounts as Record; + for (const [endpoint, count] of Object.entries(counts)) { + endpointCounts[endpoint] = (endpointCounts[endpoint] || 0) + count; + } + } + + // Sort endpoints by count + const sortedEndpoints = Object.entries(endpointCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20); // Top 20 + + // Get usage by API key (if not filtering by specific key) + let byApiKey: { keyPrefix: string; name: string; requests: number }[] = []; + if (!apiKeyId) { + const apiKeyUsage = await prisma.apiUsageSummary.groupBy({ + by: ['apiKeyId'], + where: { + userId, + periodType: period, + periodStart: { + gte: start, + lte: end, + }, + apiKeyId: { not: null }, + }, + _sum: { + totalRequests: true, + }, + }); + + // Fetch key details + const keyIds = apiKeyUsage.map((u) => u.apiKeyId).filter(Boolean) as string[]; + const keys = await prisma.tpmjsApiKey.findMany({ + where: { id: { in: keyIds } }, + select: { id: true, name: true, keyPrefix: true }, + }); + + const keyMap = new Map(keys.map((k) => [k.id, k])); + + byApiKey = apiKeyUsage + .filter((u) => u.apiKeyId && keyMap.has(u.apiKeyId)) + .map((u) => { + const key = keyMap.get(u.apiKeyId!)!; + return { + keyPrefix: key.keyPrefix, + name: key.name, + requests: u._sum.totalRequests || 0, + }; + }) + .sort((a, b) => b.requests - a.requests); + } + + // Format time series data + const timeSeries = summaries.map((s) => ({ + periodStart: s.periodStart.toISOString(), + totalRequests: s.totalRequests, + successRequests: s.successRequests, + errorRequests: s.errorRequests, + totalTokensIn: s.totalTokensIn, + totalTokensOut: s.totalTokensOut, + avgLatencyMs: s.avgLatencyMs, + })); + + return NextResponse.json({ + success: true, + data: { + period, + dateRange: { + start: start.toISOString(), + end: end.toISOString(), + }, + summary: { + ...totals, + successRate: + totals.totalRequests > 0 + ? Math.round((totals.successRequests / totals.totalRequests) * 100) + : 0, + }, + timeSeries, + byEndpoint: sortedEndpoints.map(([endpoint, count]) => ({ + endpoint, + count, + })), + byApiKey, + }, + }); + } catch (error) { + console.error('[Usage] Error fetching usage:', error); + return NextResponse.json({ error: 'Failed to fetch usage data' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/dashboard/settings/tpmjs-api-keys/page.tsx b/apps/web/src/app/dashboard/settings/tpmjs-api-keys/page.tsx new file mode 100644 index 0000000..e959009 --- /dev/null +++ b/apps/web/src/app/dashboard/settings/tpmjs-api-keys/page.tsx @@ -0,0 +1,437 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { + Table, + TableBody, + TableCell, + TableEmpty, + TableHead, + TableHeader, + TableRow, +} from '@tpmjs/ui/Table/Table'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; + +interface TpmjsApiKey { + id: string; + name: string; + keyPrefix: string; + maskedKey: string; + scopes: string[]; + isActive: boolean; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +function formatRelativeDate(dateString: string | null): string { + if (!dateString) return 'Never'; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return formatDate(dateString); +} + +export default function TpmjsApiKeysPage(): React.ReactElement { + const router = useRouter(); + const [keys, setKeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Create key form + const [showCreateForm, setShowCreateForm] = useState(false); + const [newKeyName, setNewKeyName] = useState(''); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + // Newly created key (shown once) + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copied, setCopied] = useState(false); + + // Delete/rotate state + const [deletingId, setDeletingId] = useState(null); + const [rotatingId, setRotatingId] = useState(null); + + const fetchKeys = useCallback(async () => { + try { + const response = await fetch('/api/user/tpmjs-api-keys'); + const data = await response.json(); + if (data.success) { + setKeys(data.apiKeys); + } else { + if (response.status === 401) { + router.push('/sign-in'); + return; + } + setError(data.error || 'Failed to fetch keys'); + } + } catch (err) { + console.error('Failed to fetch keys:', err); + setError('Failed to fetch keys'); + } finally { + setIsLoading(false); + } + }, [router]); + + useEffect(() => { + fetchKeys(); + }, [fetchKeys]); + + const handleCreate = useCallback(async () => { + if (!newKeyName.trim()) return; + + setCreating(true); + setCreateError(null); + + try { + const response = await fetch('/api/user/tpmjs-api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newKeyName.trim() }), + }); + const result = await response.json(); + + if (result.success) { + setNewlyCreatedKey(result.apiKey.key); + setNewKeyName(''); + setShowCreateForm(false); + fetchKeys(); + } else { + setCreateError(result.error || 'Failed to create key'); + } + } catch (err) { + console.error('Failed to create key:', err); + setCreateError('Failed to create key'); + } finally { + setCreating(false); + } + }, [newKeyName, fetchKeys]); + + const handleDelete = useCallback(async (id: string, name: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (!confirm(`Delete API key "${name}"? This action cannot be undone.`)) return; + + setDeletingId(id); + try { + const response = await fetch(`/api/user/tpmjs-api-keys/${id}`, { + method: 'DELETE', + }); + const result = await response.json(); + if (result.success) { + setKeys((prev) => prev.filter((k) => k.id !== id)); + } + } catch (err) { + console.error('Failed to delete:', err); + } finally { + setDeletingId(null); + } + }, []); + + const handleRotate = useCallback( + async (id: string, name: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (!confirm(`Rotate API key "${name}"? The old key will be immediately invalidated.`)) + return; + + setRotatingId(id); + try { + const response = await fetch(`/api/user/tpmjs-api-keys/${id}/rotate`, { + method: 'POST', + }); + const result = await response.json(); + if (result.success) { + setNewlyCreatedKey(result.apiKey.key); + fetchKeys(); + } + } catch (err) { + console.error('Failed to rotate:', err); + } finally { + setRotatingId(null); + } + }, + [fetchKeys] + ); + + const handleToggleActive = useCallback(async (id: string, currentlyActive: boolean) => { + try { + const response = await fetch(`/api/user/tpmjs-api-keys/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !currentlyActive }), + }); + const result = await response.json(); + if (result.success) { + setKeys((prev) => + prev.map((k) => (k.id === id ? { ...k, isActive: !currentlyActive } : k)) + ); + } + } catch (err) { + console.error('Failed to toggle active:', err); + } + }, []); + + const copyToClipboard = useCallback(async (text: string) => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, []); + + if (error) { + return ( + +
+ +

Error

+

{error}

+ +
+
+ ); + } + + return ( + 0 ? `${keys.length} key${keys.length !== 1 ? 's' : ''}` : undefined} + actions={ + !showCreateForm && + !newlyCreatedKey && ( + + ) + } + > + {/* Info banner */} +
+
+ +
+

+ Use API keys to access TPMJS programmatically +

+

+ API keys allow you to call MCP endpoints, chat with agents, and connect via the bridge + without a browser session. Keep your keys secure and never share them. +

+
+
+
+ + {/* Newly created key - show only once */} + {newlyCreatedKey && ( +
+
+ +
+

API key created successfully!

+

+ Copy your key now. It will not be shown again. +

+
+ + {newlyCreatedKey} + + +
+
+ +
+
+ )} + + {/* Create key form */} + {showCreateForm && ( +
+

Create API Key

+ {createError &&

{createError}

} +
+ setNewKeyName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + placeholder="Key name (e.g., Production Server, CI/CD)" + className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-foreground text-sm placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50" + /> +
+
+ + +
+
+ )} + + {/* Keys Table */} +
+ + + + Name + Key + Status + Last Used + Created + Actions + + + + {isLoading ? ( + [0, 1, 2].map((idx) => ( + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + )) + ) : keys.length === 0 ? ( + + +
+ } + title="No API keys yet" + description="Create an API key to access TPMJS programmatically from your applications, scripts, or CI/CD pipelines." + action={ + + } + /> + ) : ( + keys.map((key) => ( + + +
+
+ +
+ {key.name} +
+
+ + + {key.maskedKey} + + + + + + + + {formatRelativeDate(key.lastUsedAt)} + + + + + {formatDate(key.createdAt)} + + + +
+ + +
+
+
+ )) + )} + +
+
+ + {/* Usage hint */} + {keys.length > 0 && ( +
+

+ Use your API key in the{' '} + + Authorization + {' '} + header: +

+
+            {`Authorization: Bearer tpmjs_sk_...`}
+          
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/dashboard/usage/page.tsx b/apps/web/src/app/dashboard/usage/page.tsx new file mode 100644 index 0000000..649cadb --- /dev/null +++ b/apps/web/src/app/dashboard/usage/page.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; + +interface UsageData { + period: string; + dateRange: { + start: string; + end: string; + }; + summary: { + totalRequests: number; + successRequests: number; + errorRequests: number; + totalTokensIn: number; + totalTokensOut: number; + estimatedCostCents: number; + successRate: number; + }; + timeSeries: Array<{ + periodStart: string; + totalRequests: number; + successRequests: number; + errorRequests: number; + totalTokensIn: number; + totalTokensOut: number; + avgLatencyMs: number; + }>; + byEndpoint: Array<{ + endpoint: string; + count: number; + }>; + byApiKey: Array<{ + keyPrefix: string; + name: string; + requests: number; + }>; +} + +type Period = 'hourly' | 'daily' | 'monthly'; + +function formatNumber(num: number): string { + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; + return num.toString(); +} + +function formatCost(cents: number): string { + if (cents === 0) return '$0.00'; + return `$${(cents / 100).toFixed(2)}`; +} + +export default function UsagePage(): React.ReactElement { + const router = useRouter(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [period, setPeriod] = useState('daily'); + + const fetchUsage = useCallback(async () => { + setIsLoading(true); + try { + const response = await fetch(`/api/user/usage?period=${period}`); + const result = await response.json(); + if (result.success) { + setData(result.data); + setError(null); + } else { + if (response.status === 401) { + router.push('/sign-in'); + return; + } + setError(result.error || 'Failed to fetch usage'); + } + } catch (err) { + console.error('Failed to fetch usage:', err); + setError('Failed to fetch usage'); + } finally { + setIsLoading(false); + } + }, [period, router]); + + useEffect(() => { + fetchUsage(); + }, [fetchUsage]); + + if (error) { + return ( + +
+ +

Error

+

{error}

+ +
+
+ ); + } + + return ( + + + + } + > + {isLoading ? ( +
+ {/* Summary cards skeleton */} +
+ {[0, 1, 2, 3].map((idx) => ( +
+
+
+
+ ))} +
+ + {/* Chart skeleton */} +
+
+
+
+
+ ) : data ? ( +
+ {/* Summary Cards */} +
+
+
+ + Total Requests +
+
+ {formatNumber(data.summary.totalRequests)} +
+
+ + {data.summary.successRate}% success + +
+
+ +
+
+ + Success / Errors +
+
+ + {formatNumber(data.summary.successRequests)} + + / + + {formatNumber(data.summary.errorRequests)} + +
+
+ +
+
+ + Tokens Used +
+
+ {formatNumber(data.summary.totalTokensIn + data.summary.totalTokensOut)} +
+
+ {formatNumber(data.summary.totalTokensIn)} in /{' '} + {formatNumber(data.summary.totalTokensOut)} out +
+
+ +
+
+ + Estimated Cost +
+
+ {formatCost(data.summary.estimatedCostCents)} +
+
+ Last{' '} + {period === 'hourly' ? '24 hours' : period === 'daily' ? '30 days' : '12 months'} +
+
+
+ + {/* Time Series Chart (simplified bar representation) */} + {data.timeSeries.length > 0 && ( +
+

+ Requests Over Time ({period}) +

+
+ {data.timeSeries.slice(-30).map((point, idx) => { + const maxRequests = Math.max(...data.timeSeries.map((p) => p.totalRequests)); + const height = maxRequests > 0 ? (point.totalRequests / maxRequests) * 100 : 0; + const successHeight = + point.totalRequests > 0 + ? (point.successRequests / point.totalRequests) * height + : 0; + + return ( +
+
+
+
+ ); + })} +
+
+ + {data.timeSeries[0]?.periodStart && + new Date(data.timeSeries[0].periodStart).toLocaleDateString()} + + + {data.timeSeries.at(-1)?.periodStart && + new Date(data.timeSeries.at(-1)!.periodStart).toLocaleDateString()} + +
+
+ )} + + {/* Usage by Endpoint and API Key */} +
+ {/* By Endpoint */} +
+

Top Endpoints

+ {data.byEndpoint.length === 0 ? ( +

No endpoint data yet

+ ) : ( +
+ {data.byEndpoint.slice(0, 10).map((endpoint, idx) => { + const maxCount = data.byEndpoint[0]?.count || 1; + const percentage = (endpoint.count / maxCount) * 100; + + return ( +
+
+ + {endpoint.endpoint} + + + {formatNumber(endpoint.count)} + +
+
+
+
+
+ ); + })} +
+ )} +
+ + {/* By API Key */} +
+

Usage by API Key

+ {data.byApiKey.length === 0 ? ( +

No API key usage yet

+ ) : ( +
+ {data.byApiKey.slice(0, 10).map((key, idx) => { + const maxRequests = data.byApiKey[0]?.requests || 1; + const percentage = (key.requests / maxRequests) * 100; + + return ( +
+
+
+ + {key.name} + + {key.keyPrefix}... + +
+ + {formatNumber(key.requests)} + +
+
+
+
+
+ ); + })} +
+ )} +
+
+ + {/* Empty state for no data */} + {data.summary.totalRequests === 0 && ( +
+
+ +
+

No usage data yet

+

+ Start using your API keys to see usage statistics here. +

+ +
+ )} +
+ ) : null} + + ); +} diff --git a/apps/web/src/app/docs/api/page.tsx b/apps/web/src/app/docs/api/page.tsx index 7e56d95..c66ddac 100644 --- a/apps/web/src/app/docs/api/page.tsx +++ b/apps/web/src/app/docs/api/page.tsx @@ -308,8 +308,8 @@ export default function APIDocsPage(): React.ReactElement {

- Try these examples to get started immediately. All public endpoints work without - authentication. + Try these examples to get started. All API endpoints require authentication via API + key. Generate one from Settings → TPMJS API Keys in your dashboard.

@@ -317,7 +317,8 @@ export default function APIDocsPage(): React.ReactElement {

1. List Tools

@@ -325,7 +326,8 @@ export default function APIDocsPage(): React.ReactElement {

2. Search Tools

@@ -335,7 +337,8 @@ export default function APIDocsPage(): React.ReactElement {
@@ -346,6 +349,7 @@ export default function APIDocsPage(): React.ReactElement { @@ -355,29 +359,51 @@ export default function APIDocsPage(): React.ReactElement {

- Most public endpoints don't require authentication. Private endpoints (creating - collections, managing agents) require a session cookie from signing in. + All API endpoints require authentication via TPMJS API keys. Generate an API key + from your dashboard at Settings → TPMJS API Keys.

-
-
-

Public (No Auth)

-
    -
  • GET /api/tools - List and search tools
  • -
  • GET /api/public/collections - List public collections
  • -
  • GET /api/public/agents - List public agents
  • -
  • POST /api/mcp/[user]/[slug]/http - MCP protocol for public collections
  • -
  • GET /api/stats - Platform statistics
  • -
+
+
+

API Key Format

+

+ API keys use the tpmjs_sk_ prefix and are + passed in the Authorization header: +

+
-

Authenticated

+

API Key Scopes

    -
  • POST /api/collections - Create collection
  • -
  • POST /api/agents - Create agent
  • -
  • PUT /api/collections/[id] - Update collection
  • -
  • DELETE /api/agents/[id] - Delete agent
  • +
  • + mcp:execute - MCP tool execution +
  • +
  • + agent:chat - Agent conversations +
  • +
  • + bridge:connect - Bridge connections +
  • +
  • + collection:read - Collection access +
  • +
  • + usage:read - Usage analytics +
  • +
+
+ +
+

Rate Limits

+
    +
  • FREE tier: 100 requests/hour
  • +
  • PRO tier: 1,000 requests/hour
  • +
  • ENTERPRISE tier: 10,000 requests/hour
@@ -590,6 +616,9 @@ export default function APIDocsPage(): React.ReactElement {

Request Headers

+ Authorization: Bearer tpmjs_sk_your_api_key_here + + Content-Type: application/json
@@ -725,6 +754,7 @@ export default function APIDocsPage(): React.ReactElement { = 40; +} + +/** + * Masks an API key for safe display + * + * @param keyPrefix - The key prefix (first 16 chars) + * @returns Masked string like "tpmjs_sk_abc1..." + */ +export function maskApiKey(keyPrefix: string): string { + return `${keyPrefix}...`; +} + +/** + * API key scopes for granular permissions + */ +export const API_KEY_SCOPES = { + /** Execute MCP tools */ + MCP_EXECUTE: 'mcp:execute', + /** Chat with agents */ + AGENT_CHAT: 'agent:chat', + /** Connect via bridge */ + BRIDGE_CONNECT: 'bridge:connect', + /** Read usage data */ + USAGE_READ: 'usage:read', + /** Read collection data */ + COLLECTION_READ: 'collection:read', +} as const; + +export type ApiKeyScope = (typeof API_KEY_SCOPES)[keyof typeof API_KEY_SCOPES]; + +/** + * Default scopes for new API keys + */ +export const DEFAULT_API_KEY_SCOPES: ApiKeyScope[] = [ + API_KEY_SCOPES.MCP_EXECUTE, + API_KEY_SCOPES.AGENT_CHAT, + API_KEY_SCOPES.BRIDGE_CONNECT, + API_KEY_SCOPES.USAGE_READ, + API_KEY_SCOPES.COLLECTION_READ, +]; + +/** + * Rate limits by user tier (requests per hour) + */ +export const RATE_LIMITS_BY_TIER = { + FREE: 100, + PRO: 1000, + ENTERPRISE: 10000, +} as const; + +/** + * Gets the rate limit for a user tier + */ +export function getRateLimitForTier(tier: keyof typeof RATE_LIMITS_BY_TIER): number { + return RATE_LIMITS_BY_TIER[tier]; +} diff --git a/apps/web/src/lib/api-keys/middleware.ts b/apps/web/src/lib/api-keys/middleware.ts new file mode 100644 index 0000000..404d2a3 --- /dev/null +++ b/apps/web/src/lib/api-keys/middleware.ts @@ -0,0 +1,225 @@ +import type { UserTier } from '@prisma/client'; +import { prisma } from '@tpmjs/db'; +import { headers } from 'next/headers'; +import { auth } from '~/lib/auth'; +import { type ApiKeyScope, hashApiKey, isValidApiKeyFormat } from './index'; + +/** + * Result of authentication attempt + */ +export interface AuthResult { + /** Whether the request is authenticated */ + authenticated: boolean; + /** User ID if authenticated */ + userId?: string; + /** API key ID if authenticated via API key */ + apiKeyId?: string; + /** Scopes available to this authentication */ + scopes?: string[]; + /** User's tier for rate limiting */ + tier?: UserTier; + /** Error message if authentication failed */ + error?: string; + /** Whether this is a session-based auth (vs API key) */ + isSessionAuth?: boolean; +} + +/** + * Authenticates a request using either session or API key + * + * Session auth is checked first (for dashboard users). + * If no session, API key auth is attempted. + * + * @returns AuthResult with authentication details + * + * @example + * const auth = await authenticateRequest(); + * if (!auth.authenticated) { + * return NextResponse.json({ error: auth.error }, { status: 401 }); + * } + * // Use auth.userId, auth.apiKeyId, auth.scopes, auth.tier + */ +export async function authenticateRequest(): Promise { + // 1. Try session auth first (for dashboard users) + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (session?.user?.id) { + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { tier: true }, + }); + return { + authenticated: true, + userId: session.user.id, + tier: user?.tier || 'FREE', + scopes: ['*'], // Session users have full access to their own resources + isSessionAuth: true, + }; + } + } catch { + // Session auth failed, try API key auth + } + + // 2. Try API key auth + const headersList = await headers(); + const authHeader = headersList.get('authorization'); + + if (!authHeader) { + return { authenticated: false, error: 'Missing authorization header' }; + } + + if (!authHeader.startsWith('Bearer ')) { + return { + authenticated: false, + error: 'Invalid authorization header format. Use: Bearer ', + }; + } + + const rawKey = authHeader.slice(7); + + if (!rawKey) { + return { authenticated: false, error: 'API key is empty' }; + } + + if (!isValidApiKeyFormat(rawKey)) { + return { + authenticated: false, + error: 'Invalid API key format. Keys must start with tpmjs_sk_', + }; + } + + const keyHash = hashApiKey(rawKey); + + const apiKey = await prisma.tpmjsApiKey.findUnique({ + where: { keyHash }, + include: { user: { select: { tier: true } } }, + }); + + if (!apiKey) { + return { authenticated: false, error: 'Invalid API key' }; + } + + if (!apiKey.isActive) { + return { authenticated: false, error: 'API key is inactive' }; + } + + if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { + return { authenticated: false, error: 'API key has expired' }; + } + + // Update last used timestamp (fire and forget - non-blocking) + prisma.tpmjsApiKey + .update({ + where: { id: apiKey.id }, + data: { lastUsedAt: new Date() }, + }) + .catch(() => { + // Ignore errors - this is just for tracking + }); + + return { + authenticated: true, + userId: apiKey.userId, + apiKeyId: apiKey.id, + scopes: apiKey.scopes, + tier: apiKey.user.tier, + isSessionAuth: false, + }; +} + +/** + * Checks if an auth result has a required scope + * + * Session auth always has all scopes ('*'). + * API key auth checks the specific scopes granted. + * + * @param authResult - The authentication result + * @param requiredScope - The scope to check + * @returns True if the auth has the required scope + * + * @example + * const auth = await authenticateRequest(); + * if (!hasScope(auth, 'mcp:execute')) { + * return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + * } + */ +export function hasScope(authResult: AuthResult, requiredScope: ApiKeyScope): boolean { + if (!authResult.authenticated || !authResult.scopes) { + return false; + } + + // Session auth has full access + if (authResult.scopes.includes('*')) { + return true; + } + + return authResult.scopes.includes(requiredScope); +} + +/** + * Requires authentication and optionally a specific scope + * + * This is a convenience wrapper that returns an error response if auth fails. + * + * @param requiredScope - Optional scope to require + * @returns AuthResult if authenticated, or null with error details + * + * @example + * const { auth, errorResponse } = await requireAuth('mcp:execute'); + * if (errorResponse) return errorResponse; + * // auth is guaranteed to be valid here + */ +export async function requireAuth( + requiredScope?: ApiKeyScope +): Promise<{ auth: AuthResult | null; errorResponse: Response | null }> { + const authResult = await authenticateRequest(); + + if (!authResult.authenticated) { + return { + auth: null, + errorResponse: new Response(JSON.stringify({ error: authResult.error }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + }; + } + + if (requiredScope && !hasScope(authResult, requiredScope)) { + return { + auth: null, + errorResponse: new Response( + JSON.stringify({ + error: `Missing required scope: ${requiredScope}`, + requiredScope, + availableScopes: authResult.scopes, + }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + } + ), + }; + } + + return { auth: authResult, errorResponse: null }; +} + +/** + * Extracts client metadata from request headers + * + * @returns Object with userAgent and ipAddress + */ +export async function getClientMetadata(): Promise<{ + userAgent: string | null; + ipAddress: string | null; +}> { + const headersList = await headers(); + + return { + userAgent: headersList.get('user-agent'), + ipAddress: + headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || + headersList.get('x-real-ip') || + null, + }; +} diff --git a/apps/web/src/lib/api-keys/rate-limit.ts b/apps/web/src/lib/api-keys/rate-limit.ts new file mode 100644 index 0000000..cc300ef --- /dev/null +++ b/apps/web/src/lib/api-keys/rate-limit.ts @@ -0,0 +1,208 @@ +import type { UserTier } from '@prisma/client'; +import { kv } from '@vercel/kv'; +import { RATE_LIMITS_BY_TIER } from './index'; + +/** + * Rate limiting for API keys using Vercel KV + * + * Each API key has a rate limit based on the user's tier. + * Limits are enforced per hour (rolling window). + */ + +// Check if Vercel KV is available +const isKVAvailable = !!process.env.KV_REST_API_URL; + +// In-memory fallback for development +const memoryStore = new Map(); + +/** + * Result of a rate limit check + */ +export interface RateLimitResult { + /** Whether the request is allowed */ + allowed: boolean; + /** Remaining requests in the current window */ + remaining: number; + /** When the rate limit resets (window end) */ + resetAt: Date; + /** Total limit for the window */ + limit: number; + /** Current request count in window */ + current: number; +} + +/** + * Get the hourly window start time (aligned to clock hour) + */ +function getHourlyWindowStart(): number { + const now = Date.now(); + const hourMs = 60 * 60 * 1000; + return Math.floor(now / hourMs) * hourMs; +} + +/** + * Check rate limit for an API key using Vercel KV + * + * @param identifier - API key ID or user ID (for session auth) + * @param tier - User's tier for determining rate limit + * @param customLimit - Optional custom limit (overrides tier default) + * @returns RateLimitResult with allowed status and metadata + * + * @example + * const result = await checkApiKeyRateLimit(apiKeyId, 'FREE'); + * if (!result.allowed) { + * return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 }); + * } + */ +export async function checkApiKeyRateLimit( + identifier: string, + tier: UserTier, + customLimit?: number | null +): Promise { + const limit = customLimit ?? RATE_LIMITS_BY_TIER[tier]; + const windowMs = 60 * 60 * 1000; // 1 hour + const windowStart = getHourlyWindowStart(); + const windowEnd = windowStart + windowMs; + const resetAt = new Date(windowEnd); + + const key = `apikey:ratelimit:${identifier}:${windowStart}`; + + if (isKVAvailable) { + return checkRateLimitKV(key, limit, windowMs, resetAt); + } + + return checkRateLimitMemory(key, limit, windowStart, resetAt); +} + +/** + * Check rate limit using Vercel KV (distributed) + */ +async function checkRateLimitKV( + key: string, + limit: number, + windowMs: number, + resetAt: Date +): Promise { + try { + // Increment counter atomically + const current = await kv.incr(key); + + // Set expiry on first request in window + if (current === 1) { + await kv.expire(key, Math.ceil(windowMs / 1000) + 60); // Add 60s buffer + } + + const remaining = Math.max(0, limit - current); + const allowed = current <= limit; + + return { + allowed, + remaining, + resetAt, + limit, + current, + }; + } catch (error) { + console.error('[API Key Rate Limit] KV error:', error); + // On error, allow the request but log the issue + return { + allowed: true, + remaining: limit, + resetAt, + limit, + current: 0, + }; + } +} + +/** + * Check rate limit using in-memory store (fallback) + */ +function checkRateLimitMemory( + key: string, + limit: number, + windowStart: number, + resetAt: Date +): RateLimitResult { + let entry = memoryStore.get(key); + + // Reset if window has changed + if (!entry || entry.windowStart !== windowStart) { + entry = { count: 0, windowStart }; + memoryStore.set(key, entry); + } + + // Increment count + entry.count++; + + const remaining = Math.max(0, limit - entry.count); + const allowed = entry.count <= limit; + + // Cleanup old entries periodically + if (Math.random() < 0.01) { + // 1% chance per request + cleanupMemoryStore(windowStart); + } + + return { + allowed, + remaining, + resetAt, + limit, + current: entry.count, + }; +} + +/** + * Cleanup old entries from memory store + */ +function cleanupMemoryStore(currentWindowStart: number): void { + for (const [key, entry] of memoryStore.entries()) { + if (entry.windowStart < currentWindowStart) { + memoryStore.delete(key); + } + } +} + +/** + * Get rate limit headers for a response + * + * @param result - Rate limit result + * @returns Headers object to add to response + */ +export function getRateLimitHeaders(result: RateLimitResult): Record { + return { + 'X-RateLimit-Limit': result.limit.toString(), + 'X-RateLimit-Remaining': result.remaining.toString(), + 'X-RateLimit-Reset': Math.ceil(result.resetAt.getTime() / 1000).toString(), + }; +} + +/** + * Create a rate limited response (429) + * + * @param result - Rate limit result + * @returns Response with 429 status and rate limit headers + */ +export function createRateLimitResponse(result: RateLimitResult): Response { + const retryAfterSeconds = Math.ceil((result.resetAt.getTime() - Date.now()) / 1000); + + return new Response( + JSON.stringify({ + error: 'Rate limit exceeded', + message: `Too many requests. Please try again in ${retryAfterSeconds} seconds.`, + retryAfter: retryAfterSeconds, + limit: result.limit, + remaining: 0, + resetAt: result.resetAt.toISOString(), + }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': retryAfterSeconds.toString(), + ...getRateLimitHeaders(result), + }, + } + ); +} diff --git a/apps/web/src/lib/api-keys/usage.ts b/apps/web/src/lib/api-keys/usage.ts new file mode 100644 index 0000000..521c9f2 --- /dev/null +++ b/apps/web/src/lib/api-keys/usage.ts @@ -0,0 +1,272 @@ +import { prisma } from '@tpmjs/db'; + +/** + * Usage tracking for API keys + * + * Tracks individual requests and maintains hourly summaries. + * All tracking is non-blocking to avoid impacting request latency. + */ + +/** + * Event data for usage tracking + */ +export interface UsageEvent { + /** API key ID (required for API key auth) */ + apiKeyId?: string; + /** User ID (required) */ + userId: string; + /** Request endpoint (e.g., "/api/mcp/user/collection/streamable-http") */ + endpoint: string; + /** HTTP method */ + method: string; + /** HTTP status code */ + statusCode: number; + /** Request latency in milliseconds */ + latencyMs: number; + /** Resource type (e.g., "mcp", "agent", "bridge", "collection") */ + resourceType?: string; + /** Resource ID (e.g., collection ID, agent ID) */ + resourceId?: string; + /** Input tokens (for LLM requests) */ + tokensIn?: number; + /** Output tokens (for LLM requests) */ + tokensOut?: number; + /** Model used (for LLM requests) */ + model?: string; + /** Error code if request failed */ + errorCode?: string; + /** Error message if request failed */ + errorMessage?: string; + /** User agent string */ + userAgent?: string | null; + /** Client IP address */ + ipAddress?: string | null; +} + +/** + * Track API usage (fire and forget - non-blocking) + * + * This function returns immediately and tracks usage in the background. + * Errors are logged but don't affect the calling code. + * + * @param event - Usage event data + * + * @example + * trackUsage({ + * apiKeyId: auth.apiKeyId, + * userId: auth.userId, + * endpoint: '/api/mcp/...', + * method: 'POST', + * statusCode: 200, + * latencyMs: 150, + * resourceType: 'mcp', + * resourceId: collectionId, + * }); + */ +export function trackUsage(event: UsageEvent): void { + // Fire and forget - don't await + trackUsageAsync(event).catch((error) => { + console.error('[Usage Tracking] Error tracking usage:', error); + }); +} + +/** + * Async implementation of usage tracking + */ +async function trackUsageAsync(event: UsageEvent): Promise { + const now = new Date(); + + // 1. Create individual record (only if authenticated via API key) + if (event.apiKeyId) { + await prisma.apiUsageRecord.create({ + data: { + apiKeyId: event.apiKeyId, + endpoint: event.endpoint, + method: event.method, + statusCode: event.statusCode, + latencyMs: event.latencyMs, + resourceType: event.resourceType, + resourceId: event.resourceId, + tokensIn: event.tokensIn, + tokensOut: event.tokensOut, + model: event.model, + errorCode: event.errorCode, + errorMessage: event.errorMessage, + userAgent: event.userAgent?.substring(0, 500), // Truncate to fit DB column + ipAddress: event.ipAddress?.substring(0, 45), + }, + }); + } + + // 2. Update hourly summary (upsert) + const hourStart = new Date(now); + hourStart.setMinutes(0, 0, 0); + + const isSuccess = event.statusCode < 400; + const isError = event.statusCode >= 400; + + // Create a normalized endpoint for summary (remove dynamic segments) + const normalizedEndpoint = normalizeEndpoint(event.endpoint); + + await prisma.apiUsageSummary.upsert({ + where: { + userId_apiKeyId_periodType_periodStart: { + userId: event.userId, + apiKeyId: event.apiKeyId || '', + periodType: 'hourly', + periodStart: hourStart, + }, + }, + create: { + userId: event.userId, + apiKeyId: event.apiKeyId, + periodType: 'hourly', + periodStart: hourStart, + totalRequests: 1, + successRequests: isSuccess ? 1 : 0, + errorRequests: isError ? 1 : 0, + endpointCounts: { [normalizedEndpoint]: 1 }, + totalTokensIn: event.tokensIn || 0, + totalTokensOut: event.tokensOut || 0, + avgLatencyMs: event.latencyMs, + }, + update: { + totalRequests: { increment: 1 }, + successRequests: { increment: isSuccess ? 1 : 0 }, + errorRequests: { increment: isError ? 1 : 0 }, + totalTokensIn: { increment: event.tokensIn || 0 }, + totalTokensOut: { increment: event.tokensOut || 0 }, + // Note: For proper running average, we'd need to fetch current values + // For now, we'll update avgLatencyMs via a background job + }, + }); + + // Update endpoint counts separately (JSON increment isn't supported directly) + // We do this via raw SQL for efficiency + try { + await prisma.$executeRaw` + UPDATE api_usage_summaries + SET endpoint_counts = jsonb_set( + COALESCE(endpoint_counts, '{}'::jsonb), + ${`{${normalizedEndpoint}}`}::text[], + (COALESCE((endpoint_counts->${normalizedEndpoint})::int, 0) + 1)::text::jsonb + ) + WHERE user_id = ${event.userId} + AND COALESCE(api_key_id, '') = ${event.apiKeyId || ''} + AND period_type = 'hourly' + AND period_start = ${hourStart} + `; + } catch { + // Ignore JSON update errors - the main counts are still accurate + } +} + +/** + * Normalize endpoint for aggregation + * + * Replaces dynamic segments (IDs, slugs) with placeholders. + * This groups similar requests together in summaries. + * + * @param endpoint - Raw endpoint path + * @returns Normalized endpoint + */ +function normalizeEndpoint(endpoint: string): string { + return ( + (endpoint.split('?')[0] ?? endpoint) + // Replace UUIDs and CUIDs with placeholder + .replace(/\/[a-z0-9]{20,}/gi, '/:id') + // Replace numeric IDs + .replace(/\/\d+/g, '/:id') + // Limit length + .substring(0, 100) + ); +} + +/** + * Create a usage tracker wrapper for route handlers + * + * This makes it easy to track usage in route handlers. + * + * @param userId - User ID + * @param apiKeyId - Optional API key ID + * @returns Object with track method and helper functions + * + * @example + * const tracker = createUsageTracker(auth.userId, auth.apiKeyId); + * tracker.track({ + * endpoint: '/api/mcp/...', + * method: 'POST', + * statusCode: 200, + * latencyMs: 150, + * }); + */ +export function createUsageTracker(userId: string, apiKeyId?: string) { + const startTime = Date.now(); + + return { + /** + * Track a usage event + */ + track( + event: Omit & { + userId?: string; + apiKeyId?: string; + } + ) { + trackUsage({ + ...event, + userId: event.userId || userId, + apiKeyId: event.apiKeyId || apiKeyId, + }); + }, + + /** + * Track completion with automatic latency calculation + */ + trackCompletion( + event: Omit & { + userId?: string; + apiKeyId?: string; + latencyMs?: number; + } + ) { + trackUsage({ + ...event, + userId: event.userId || userId, + apiKeyId: event.apiKeyId || apiKeyId, + latencyMs: event.latencyMs || Date.now() - startTime, + }); + }, + + /** + * Get elapsed time since tracker creation + */ + getElapsedMs() { + return Date.now() - startTime; + }, + }; +} + +/** + * Cleanup old usage records (called by cron job) + * + * Deletes individual records older than 30 days. + * Summaries are kept for longer-term analytics. + * + * @param daysToKeep - Number of days to keep records (default 30) + * @returns Number of records deleted + */ +export async function cleanupOldUsageRecords(daysToKeep = 30): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - daysToKeep); + + const result = await prisma.apiUsageRecord.deleteMany({ + where: { + createdAt: { + lt: cutoff, + }, + }, + }); + + return result.count; +} diff --git a/docs/MCP-AGGREGATOR-DESIGN.md b/docs/MCP-AGGREGATOR-DESIGN.md index 5010014..d22264a 100644 --- a/docs/MCP-AGGREGATOR-DESIGN.md +++ b/docs/MCP-AGGREGATOR-DESIGN.md @@ -401,9 +401,9 @@ class TPMJSBridge { }))); } - // 3. Connect to TPMJS WebSocket + // 3. Connect to TPMJS WebSocket (requires API key with bridge:connect scope) this.ws = new WebSocket( - `${this.config.tpmjsUrl}/api/bridge?token=${this.config.apiKey}` + `${this.config.tpmjsUrl}/api/bridge?token=${this.config.apiKey}` // apiKey format: tpmjs_sk_... ); this.ws.on('open', () => { @@ -443,6 +443,7 @@ class TPMJSBridge { } // CLI entry point +// API key is loaded from ~/.tpmjs/credentials.json (format: tpmjs_sk_...) const config = loadConfig(); // from ~/.tpmjs/bridge.json const bridge = new TPMJSBridge(config); bridge.start(); @@ -452,9 +453,12 @@ bridge.start(); Server-side handler for bridge connections. +**Authentication:** Requires TPMJS API key (format: `tpmjs_sk_...`) with `bridge:connect` scope. + ```typescript // apps/web/src/app/api/bridge/route.ts import { prisma } from '@tpmjs/db'; +import { authenticateRequest, hasScope } from '~/lib/api-keys/middleware'; export const runtime = 'nodejs'; @@ -463,9 +467,9 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const token = searchParams.get('token'); - // Validate API key - const user = await validateApiKey(token); - if (!user) { + // Validate API key (must have bridge:connect scope) + const authResult = await authenticateRequest(); + if (!authResult.authenticated || !hasScope(authResult, 'bridge:connect')) { return new Response('Unauthorized', { status: 401 }); } @@ -715,11 +719,19 @@ interface ToolExecutionError { ### Security Considerations -1. **API Key Authentication**: Bridge connections require valid API key -2. **User Isolation**: Each user's bridge is isolated -3. **Tool Whitelisting**: Users explicitly add tools to collections -4. **Encrypted Credentials**: Remote MCP server credentials encrypted at rest -5. **WebSocket Security**: WSS (TLS) required for bridge connections +1. **API Key Authentication**: All API endpoints require a valid TPMJS API key (`tpmjs_sk_...` prefix) +2. **Scope-Based Access**: API keys have specific scopes (e.g., `bridge:connect`, `mcp:execute`, `agent:chat`) +3. **User Isolation**: Each user's bridge is isolated +4. **Tool Whitelisting**: Users explicitly add tools to collections +5. **Encrypted Credentials**: Remote MCP server credentials encrypted at rest +6. **WebSocket Security**: WSS (TLS) required for bridge connections + +**Required API Key Scopes:** +- `bridge:connect` - For bridge WebSocket connections +- `mcp:execute` - For MCP tool execution +- `collection:read` - For accessing collection data + +Generate API keys from Settings > TPMJS API Keys in the dashboard. --- @@ -819,12 +831,17 @@ After setup, user only needs ONE MCP server in their config: "mcpServers": { "tpmjs": { "type": "url", - "url": "https://tpmjs.com/api/mcp/username/all-my-tools/http" + "url": "https://tpmjs.com/api/mcp/username/all-my-tools/http", + "headers": { + "Authorization": "Bearer tpmjs_sk_your_api_key_here" + } } } } ``` +**Note:** Generate your API key from Settings > TPMJS API Keys. The key requires `mcp:execute` scope. + This single endpoint provides access to: - All npm tools in the collection - All remote MCP tools configured diff --git a/docs/PRD-MCP-BRIDGE.md b/docs/PRD-MCP-BRIDGE.md index 8db70fe..573bf52 100644 --- a/docs/PRD-MCP-BRIDGE.md +++ b/docs/PRD-MCP-BRIDGE.md @@ -648,7 +648,7 @@ npx @tpmjs/bridge **~/.tpmjs/credentials.json** ```json { - "apiKey": "tpmjs_xxxxxxxxxxxxxxxxxxxx", + "apiKey": "tpmjs_sk_xxxxxxxxxxxxxxxxxxxx", "userId": "user_abc123", "email": "user@example.com", "expiresAt": "2026-01-12T00:00:00Z" @@ -703,9 +703,11 @@ await manager.disconnect('chrome'); #### Connection ``` -wss://tpmjs.com/api/bridge?token=tpmjs_xxxx +wss://tpmjs.com/api/bridge?token=tpmjs_sk_your_api_key_here ``` +**Note:** All TPMJS API endpoints require authentication. Generate an API key from your dashboard at Settings > TPMJS API Keys. API keys use the `tpmjs_sk_` prefix. + #### Messages: Bridge → TPMJS **Register Tools** @@ -907,16 +909,28 @@ ALTER TABLE "CollectionBridgeTool" ADD CONSTRAINT "CollectionBridgeTool_collecti **Endpoint**: `GET /api/bridge` **Query Parameters**: -- `token` (required): User's API key +- `token` (required): User's TPMJS API key (format: `tpmjs_sk_...`) **Upgrade**: WebSocket -**Authentication**: Validates API key, returns 401 if invalid +**Authentication**: Validates API key with `bridge:connect` scope, returns 401 if invalid + +**Example**: +```bash +# Connect via WebSocket with API key +wscat -c 'wss://tpmjs.com/api/bridge?token=tpmjs_sk_your_api_key_here' +``` ### Bridge Status API **Endpoint**: `GET /api/user/bridge` +**Authentication**: Requires API key with `bridge:connect` scope +```bash +curl https://tpmjs.com/api/user/bridge \ + -H 'Authorization: Bearer tpmjs_sk_your_api_key_here' +``` + **Response**: ```json { @@ -945,13 +959,18 @@ ALTER TABLE "CollectionBridgeTool" ADD CONSTRAINT "CollectionBridgeTool_collecti ### Collection Bridge Tools API +All collection endpoints require API key with `collection:read` scope. + **Add Tool**: `POST /api/collections/{id}/bridge-tools` -```json -{ - "serverId": "chrome-devtools", - "toolName": "screenshot" -} +```bash +curl -X POST 'https://tpmjs.com/api/collections/{id}/bridge-tools' \ + -H 'Authorization: Bearer tpmjs_sk_your_api_key_here' \ + -H 'Content-Type: application/json' \ + -d '{ + "serverId": "chrome-devtools", + "toolName": "screenshot" + }' ``` **Remove Tool**: `DELETE /api/collections/{id}/bridge-tools/{toolId}` diff --git a/docs/TOOL_HEALTH_SYSTEM.md b/docs/TOOL_HEALTH_SYSTEM.md index 4ff5b2c..a6ea9b8 100644 --- a/docs/TOOL_HEALTH_SYSTEM.md +++ b/docs/TOOL_HEALTH_SYSTEM.md @@ -159,8 +159,9 @@ This happened with the `startTime is not defined` bug. Our executor code had a b ### Investigating a Specific Tool ```bash -# Check current health status -curl -s 'https://tpmjs.com/api/tools?limit=50' | \ +# Check current health status (requires API key) +curl -s 'https://tpmjs.com/api/tools?limit=50' \ + -H 'Authorization: Bearer tpmjs_sk_your_api_key_here' | \ jq '.data[] | select(.package.npmPackageName == "PACKAGE_NAME") | { packageName: .package.npmPackageName, exportName: .exportName, @@ -179,10 +180,11 @@ cat package/dist/index.js ### Manually Updating Health Status -For testing or correction: +For testing or correction (requires API key with appropriate scope): ```bash curl -X POST 'https://tpmjs.com/api/tools/report-health' \ + -H 'Authorization: Bearer tpmjs_sk_your_api_key_here' \ -H 'Content-Type: application/json' \ -d '{ "packageName": "@scope/package", diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index d39c4a4..650d764 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -334,6 +334,9 @@ model User { image String? username String? @unique @db.VarChar(30) // URL-friendly username (nullable for migration) + // Tier for rate limiting and feature access + tier UserTier @default(FREE) + createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -347,6 +350,8 @@ model User { agentLikes AgentLike[] activities UserActivity[] bridgeConnection BridgeConnection? + tpmjsApiKeys TpmjsApiKey[] @relation("UserTpmjsApiKeys") + usageSummaries ApiUsageSummary[] @relation("UserUsageSummaries") @@index([username]) @@map("users") @@ -886,3 +891,137 @@ model CollectionBridgeTool { @@index([collectionId]) @@map("collection_bridge_tools") } + +// ============================================================================ +// API Key & Usage Tracking Models +// ============================================================================ + +/// User tier enum - determines rate limits and feature access +enum UserTier { + FREE + PRO + ENTERPRISE +} + +/// TpmjsApiKey - user-owned API keys for programmatic access +model TpmjsApiKey { + id String @id @default(cuid()) + + // Owner relationship + userId String @map("user_id") + user User @relation("UserTpmjsApiKeys", fields: [userId], references: [id], onDelete: Cascade) + + // Key identification + name String @db.VarChar(100) // User-provided name (e.g., "Production Server") + keyHash String @unique @map("key_hash") @db.VarChar(64) // SHA-256 hash (never store raw keys) + keyPrefix String @map("key_prefix") @db.VarChar(20) // First 16 chars for identification (tpmjs_sk_abc123...) + + // Permissions + scopes String[] @default([]) // ["mcp:execute", "agent:chat", "bridge:connect", "usage:read"] + + // Rate limiting (overrides tier default if set) + rateLimit Int? @map("rate_limit") // Requests per hour (null = use tier default) + + // Status + isActive Boolean @default(true) @map("is_active") + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + usageRecords ApiUsageRecord[] + + @@index([userId]) + @@index([keyHash]) + @@index([keyPrefix]) + @@index([isActive]) + @@map("tpmjs_api_keys") +} + +/// ApiUsageRecord - individual API request logs (kept for 30 days) +model ApiUsageRecord { + id String @id @default(cuid()) + + // API key relationship + apiKeyId String @map("api_key_id") + apiKey TpmjsApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade) + + // Request details + endpoint String @db.VarChar(500) + method String @db.VarChar(10) // GET, POST, etc. + statusCode Int @map("status_code") + latencyMs Int @map("latency_ms") + + // Resource tracking + resourceType String? @map("resource_type") @db.VarChar(50) // "mcp" | "agent" | "bridge" | "collection" + resourceId String? @map("resource_id") @db.VarChar(100) // Collection ID, Agent ID, etc. + + // LLM usage (if applicable) + tokensIn Int? @map("tokens_in") + tokensOut Int? @map("tokens_out") + model String? @db.VarChar(50) // e.g., "gpt-4o-mini" + + // Error tracking + errorCode String? @map("error_code") @db.VarChar(50) + errorMessage String? @map("error_message") @db.Text + + // Client metadata + userAgent String? @map("user_agent") @db.VarChar(500) + ipAddress String? @map("ip_address") @db.VarChar(45) // IPv4 or IPv6 + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + + @@index([apiKeyId]) + @@index([createdAt]) + @@index([endpoint]) + @@index([resourceType, resourceId]) + @@map("api_usage_records") +} + +/// ApiUsageSummary - aggregated usage summaries (hourly/daily rollups) +model ApiUsageSummary { + id String @id @default(cuid()) + + // User relationship + userId String @map("user_id") + user User @relation("UserUsageSummaries", fields: [userId], references: [id], onDelete: Cascade) + + // Optional API key (null for user-level summaries) + apiKeyId String? @map("api_key_id") + + // Time period + periodType String @map("period_type") @db.VarChar(20) // "hourly" | "daily" | "monthly" + periodStart DateTime @map("period_start") + + // Request counts + totalRequests Int @default(0) @map("total_requests") + successRequests Int @default(0) @map("success_requests") + errorRequests Int @default(0) @map("error_requests") + + // Endpoint breakdown (JSON: { "/api/mcp/...": 100, ... }) + endpointCounts Json @default("{}") @map("endpoint_counts") @db.JsonB + + // LLM usage totals + totalTokensIn Int @default(0) @map("total_tokens_in") + totalTokensOut Int @default(0) @map("total_tokens_out") + + // Performance + avgLatencyMs Float @default(0) @map("avg_latency_ms") + p95LatencyMs Float @default(0) @map("p95_latency_ms") + + // Cost estimation (in cents) + estimatedCostCents Int @default(0) @map("estimated_cost_cents") + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([userId, apiKeyId, periodType, periodStart]) + @@index([userId]) + @@index([periodType, periodStart]) + @@map("api_usage_summaries") +}