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
This commit is contained in:
parent
b663ca3e05
commit
a3f1f3935e
20 changed files with 2813 additions and 90 deletions
|
|
@ -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<Response> {
|
||||
// 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof authenticateRequest>> | 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' },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
||||
const startTime = Date.now();
|
||||
let authResult: Awaited<ReturnType<typeof authenticateRequest>> | 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 }
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
222
apps/web/src/app/api/user/tpmjs-api-keys/[id]/route.ts
Normal file
222
apps/web/src/app/api/user/tpmjs-api-keys/[id]/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
152
apps/web/src/app/api/user/tpmjs-api-keys/route.ts
Normal file
152
apps/web/src/app/api/user/tpmjs-api-keys/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
212
apps/web/src/app/api/user/usage/route.ts
Normal file
212
apps/web/src/app/api/user/usage/route.ts
Normal file
|
|
@ -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<string, number> = {};
|
||||
for (const summary of summaries) {
|
||||
const counts = summary.endpointCounts as Record<string, number>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
437
apps/web/src/app/dashboard/settings/tpmjs-api-keys/page.tsx
Normal file
437
apps/web/src/app/dashboard/settings/tpmjs-api-keys/page.tsx
Normal file
|
|
@ -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<TpmjsApiKey[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Create key form
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// Newly created key (shown once)
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Delete/rotate state
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [rotatingId, setRotatingId] = useState<string | null>(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 (
|
||||
<DashboardLayout title="TPMJS API Keys">
|
||||
<div className="text-center py-16">
|
||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
|
||||
<p className="text-foreground-secondary mb-4">{error}</p>
|
||||
<Button onClick={fetchKeys}>Try Again</Button>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="TPMJS API Keys"
|
||||
subtitle={keys.length > 0 ? `${keys.length} key${keys.length !== 1 ? 's' : ''}` : undefined}
|
||||
actions={
|
||||
!showCreateForm &&
|
||||
!newlyCreatedKey && (
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<Icon icon="plus" size="sm" className="mr-2" />
|
||||
Create API Key
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Info banner */}
|
||||
<div className="bg-primary/5 border border-primary/20 rounded-lg p-4 mb-6">
|
||||
<div className="flex gap-3">
|
||||
<Icon icon="info" size="sm" className="text-primary flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="text-foreground font-medium mb-1">
|
||||
Use API keys to access TPMJS programmatically
|
||||
</p>
|
||||
<p className="text-foreground-secondary">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Newly created key - show only once */}
|
||||
{newlyCreatedKey && (
|
||||
<div className="bg-success/10 border border-success/30 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon icon="check" size="sm" className="text-success flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-foreground font-medium mb-2">API key created successfully!</p>
|
||||
<p className="text-foreground-secondary text-sm mb-3">
|
||||
Copy your key now. It will not be shown again.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-surface border border-border rounded-lg p-3">
|
||||
<code className="flex-1 font-mono text-sm text-foreground break-all">
|
||||
{newlyCreatedKey}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(newlyCreatedKey)}
|
||||
>
|
||||
<Icon icon={copied ? 'check' : 'copy'} size="xs" className="mr-1" />
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => setNewlyCreatedKey(null)}>
|
||||
<Icon icon="x" size="xs" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create key form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">Create API Key</h2>
|
||||
{createError && <p className="text-error text-sm mb-3">{createError}</p>}
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Button onClick={handleCreate} disabled={creating || !newKeyName.trim()}>
|
||||
{creating ? 'Creating...' : 'Create Key'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreateForm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keys Table */}
|
||||
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">Name</TableHead>
|
||||
<TableHead>Key</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Used</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[120px] text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
[0, 1, 2].map((idx) => (
|
||||
<TableRow key={`key-skeleton-${idx}`}>
|
||||
<TableCell>
|
||||
<div className="h-4 w-32 bg-surface-secondary rounded animate-pulse" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-40 bg-surface-secondary rounded animate-pulse" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-5 w-16 bg-surface-secondary rounded animate-pulse" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-20 bg-surface-secondary rounded animate-pulse" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-24 bg-surface-secondary rounded animate-pulse" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-8 w-20 bg-surface-secondary rounded animate-pulse ml-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : keys.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={6}
|
||||
icon={
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon icon="key" size="lg" className="text-primary" />
|
||||
</div>
|
||||
}
|
||||
title="No API keys yet"
|
||||
description="Create an API key to access TPMJS programmatically from your applications, scripts, or CI/CD pipelines."
|
||||
action={
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<Icon icon="plus" size="sm" className="mr-2" />
|
||||
Create Your First Key
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Icon icon="key" size="sm" className="text-primary" />
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{key.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-foreground-tertiary font-mono text-sm">
|
||||
{key.maskedKey}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleActive(key.id, key.isActive)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge variant={key.isActive ? 'success' : 'secondary'}>
|
||||
{key.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-foreground-secondary text-sm">
|
||||
{formatRelativeDate(key.lastUsedAt)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-foreground-secondary text-sm">
|
||||
{formatDate(key.createdAt)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => handleRotate(key.id, key.name, e)}
|
||||
disabled={rotatingId === key.id}
|
||||
title="Rotate key"
|
||||
>
|
||||
<Icon icon="loader" size="xs" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => handleDelete(key.id, key.name, e)}
|
||||
disabled={deletingId === key.id}
|
||||
title="Delete key"
|
||||
>
|
||||
<Icon icon="trash" size="xs" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Usage hint */}
|
||||
{keys.length > 0 && (
|
||||
<div className="mt-6 text-sm text-foreground-tertiary">
|
||||
<p>
|
||||
Use your API key in the{' '}
|
||||
<code className="bg-surface-secondary px-1.5 py-0.5 rounded font-mono text-foreground-secondary">
|
||||
Authorization
|
||||
</code>{' '}
|
||||
header:
|
||||
</p>
|
||||
<pre className="mt-2 bg-surface-secondary border border-border rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
{`Authorization: Bearer tpmjs_sk_...`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
349
apps/web/src/app/dashboard/usage/page.tsx
Normal file
349
apps/web/src/app/dashboard/usage/page.tsx
Normal file
|
|
@ -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<UsageData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [period, setPeriod] = useState<Period>('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 (
|
||||
<DashboardLayout title="Usage">
|
||||
<div className="text-center py-16">
|
||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
|
||||
<p className="text-foreground-secondary mb-4">{error}</p>
|
||||
<Button onClick={fetchUsage}>Try Again</Button>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Usage"
|
||||
subtitle="Monitor your API usage and costs"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as Period)}
|
||||
className="px-3 py-1.5 bg-surface border border-border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="space-y-6">
|
||||
{/* Summary cards skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[0, 1, 2, 3].map((idx) => (
|
||||
<div
|
||||
key={`summary-skeleton-${idx}`}
|
||||
className="bg-surface border border-border rounded-lg p-4"
|
||||
>
|
||||
<div className="h-4 w-24 bg-surface-secondary rounded animate-pulse mb-2" />
|
||||
<div className="h-8 w-20 bg-surface-secondary rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart skeleton */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<div className="h-4 w-32 bg-surface-secondary rounded animate-pulse mb-4" />
|
||||
<div className="h-64 bg-surface-secondary rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-surface border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-foreground-secondary text-sm mb-1">
|
||||
<Icon icon="globe" size="xs" />
|
||||
Total Requests
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{formatNumber(data.summary.totalRequests)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="success" className="text-xs">
|
||||
{data.summary.successRate}% success
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-foreground-secondary text-sm mb-1">
|
||||
<Icon icon="check" size="xs" />
|
||||
Success / Errors
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-semibold text-success">
|
||||
{formatNumber(data.summary.successRequests)}
|
||||
</span>
|
||||
<span className="text-foreground-secondary">/</span>
|
||||
<span className="text-xl font-semibold text-error">
|
||||
{formatNumber(data.summary.errorRequests)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-foreground-secondary text-sm mb-1">
|
||||
<Icon icon="terminal" size="xs" />
|
||||
Tokens Used
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{formatNumber(data.summary.totalTokensIn + data.summary.totalTokensOut)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-foreground-tertiary">
|
||||
{formatNumber(data.summary.totalTokensIn)} in /{' '}
|
||||
{formatNumber(data.summary.totalTokensOut)} out
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-foreground-secondary text-sm mb-1">
|
||||
<Icon icon="info" size="xs" />
|
||||
Estimated Cost
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{formatCost(data.summary.estimatedCostCents)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-foreground-tertiary">
|
||||
Last{' '}
|
||||
{period === 'hourly' ? '24 hours' : period === 'daily' ? '30 days' : '12 months'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Series Chart (simplified bar representation) */}
|
||||
{data.timeSeries.length > 0 && (
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-4">
|
||||
Requests Over Time ({period})
|
||||
</h3>
|
||||
<div className="h-48 flex items-end gap-1">
|
||||
{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 (
|
||||
<div
|
||||
key={`bar-${idx}`}
|
||||
className="flex-1 flex flex-col justify-end"
|
||||
title={`${new Date(point.periodStart).toLocaleDateString()}: ${point.totalRequests} requests`}
|
||||
>
|
||||
<div
|
||||
className="bg-error/30 rounded-t"
|
||||
style={{ height: `${height - successHeight}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-success rounded-b"
|
||||
style={{ height: `${successHeight}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-foreground-tertiary">
|
||||
<span>
|
||||
{data.timeSeries[0]?.periodStart &&
|
||||
new Date(data.timeSeries[0].periodStart).toLocaleDateString()}
|
||||
</span>
|
||||
<span>
|
||||
{data.timeSeries.at(-1)?.periodStart &&
|
||||
new Date(data.timeSeries.at(-1)!.periodStart).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage by Endpoint and API Key */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* By Endpoint */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-4">Top Endpoints</h3>
|
||||
{data.byEndpoint.length === 0 ? (
|
||||
<p className="text-foreground-tertiary text-sm">No endpoint data yet</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.byEndpoint.slice(0, 10).map((endpoint, idx) => {
|
||||
const maxCount = data.byEndpoint[0]?.count || 1;
|
||||
const percentage = (endpoint.count / maxCount) * 100;
|
||||
|
||||
return (
|
||||
<div key={`endpoint-${idx}`}>
|
||||
<div className="flex justify-between items-center text-sm mb-1">
|
||||
<code className="text-foreground-secondary font-mono text-xs truncate max-w-[70%]">
|
||||
{endpoint.endpoint}
|
||||
</code>
|
||||
<span className="text-foreground font-medium">
|
||||
{formatNumber(endpoint.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* By API Key */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-4">Usage by API Key</h3>
|
||||
{data.byApiKey.length === 0 ? (
|
||||
<p className="text-foreground-tertiary text-sm">No API key usage yet</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.byApiKey.slice(0, 10).map((key, idx) => {
|
||||
const maxRequests = data.byApiKey[0]?.requests || 1;
|
||||
const percentage = (key.requests / maxRequests) * 100;
|
||||
|
||||
return (
|
||||
<div key={`key-${idx}`}>
|
||||
<div className="flex justify-between items-center text-sm mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="key" size="xs" className="text-foreground-tertiary" />
|
||||
<span className="text-foreground">{key.name}</span>
|
||||
<code className="text-foreground-tertiary text-xs font-mono">
|
||||
{key.keyPrefix}...
|
||||
</code>
|
||||
</div>
|
||||
<span className="text-foreground font-medium">
|
||||
{formatNumber(key.requests)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty state for no data */}
|
||||
{data.summary.totalRequests === 0 && (
|
||||
<div className="bg-surface border border-border rounded-lg p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Icon icon="globe" size="lg" className="text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">No usage data yet</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Start using your API keys to see usage statistics here.
|
||||
</p>
|
||||
<Button onClick={() => router.push('/dashboard/settings/tpmjs-api-keys')}>
|
||||
<Icon icon="key" size="sm" className="mr-2" />
|
||||
Manage API Keys
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -308,8 +308,8 @@ export default function APIDocsPage(): React.ReactElement {
|
|||
|
||||
<DocSection id="quick-start" title="Quick Start">
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
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 <strong>Settings → TPMJS API Keys</strong> in your dashboard.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
|
@ -317,7 +317,8 @@ export default function APIDocsPage(): React.ReactElement {
|
|||
<h3 className="text-lg font-semibold text-foreground mb-3">1. List Tools</h3>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl "https://tpmjs.com/api/tools?limit=5" | jq`}
|
||||
code={`curl "https://tpmjs.com/api/tools?limit=5" \\
|
||||
-H "Authorization: Bearer tpmjs_sk_your_api_key_here" | jq`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -325,7 +326,8 @@ export default function APIDocsPage(): React.ReactElement {
|
|||
<h3 className="text-lg font-semibold text-foreground mb-3">2. Search Tools</h3>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl "https://tpmjs.com/api/tools/search?q=web+scraping&limit=3" | jq`}
|
||||
code={`curl "https://tpmjs.com/api/tools/search?q=web+scraping&limit=3" \\
|
||||
-H "Authorization: Bearer tpmjs_sk_your_api_key_here" | jq`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -335,7 +337,8 @@ export default function APIDocsPage(): React.ReactElement {
|
|||
</h3>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl "https://tpmjs.com/api/tools/@tpmjs/hello/helloWorldTool" | jq`}
|
||||
code={`curl "https://tpmjs.com/api/tools/@tpmjs/hello/helloWorldTool" \\
|
||||
-H "Authorization: Bearer tpmjs_sk_your_api_key_here" | jq`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -346,6 +349,7 @@ export default function APIDocsPage(): React.ReactElement {
|
|||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl -X POST "https://tpmjs.com/api/mcp/ajax/ajax-collection-tbc/http" \\
|
||||
-H "Authorization: Bearer tpmjs_sk_your_api_key_here" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq`}
|
||||
/>
|
||||
|
|
@ -355,29 +359,51 @@ export default function APIDocsPage(): React.ReactElement {
|
|||
|
||||
<DocSection id="authentication" title="Authentication">
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
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 <strong>Settings → TPMJS API Keys</strong>.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-green-500/30 rounded-lg bg-green-500/5">
|
||||
<h3 className="font-semibold text-foreground mb-2">Public (No Auth)</h3>
|
||||
<ul className="text-sm text-foreground-secondary list-disc list-inside space-y-1">
|
||||
<li>GET /api/tools - List and search tools</li>
|
||||
<li>GET /api/public/collections - List public collections</li>
|
||||
<li>GET /api/public/agents - List public agents</li>
|
||||
<li>POST /api/mcp/[user]/[slug]/http - MCP protocol for public collections</li>
|
||||
<li>GET /api/stats - Platform statistics</li>
|
||||
</ul>
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="p-4 border border-primary/30 rounded-lg bg-primary/5">
|
||||
<h3 className="font-semibold text-foreground mb-2">API Key Format</h3>
|
||||
<p className="text-sm text-foreground-secondary mb-2">
|
||||
API keys use the <code className="text-primary">tpmjs_sk_</code> prefix and are
|
||||
passed in the Authorization header:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl "https://tpmjs.com/api/tools" \\
|
||||
-H "Authorization: Bearer tpmjs_sk_your_api_key_here"`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-yellow-500/30 rounded-lg bg-yellow-500/5">
|
||||
<h3 className="font-semibold text-foreground mb-2">Authenticated</h3>
|
||||
<h3 className="font-semibold text-foreground mb-2">API Key Scopes</h3>
|
||||
<ul className="text-sm text-foreground-secondary list-disc list-inside space-y-1">
|
||||
<li>POST /api/collections - Create collection</li>
|
||||
<li>POST /api/agents - Create agent</li>
|
||||
<li>PUT /api/collections/[id] - Update collection</li>
|
||||
<li>DELETE /api/agents/[id] - Delete agent</li>
|
||||
<li>
|
||||
<code className="text-primary">mcp:execute</code> - MCP tool execution
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-primary">agent:chat</code> - Agent conversations
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-primary">bridge:connect</code> - Bridge connections
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-primary">collection:read</code> - Collection access
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-primary">usage:read</code> - Usage analytics
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-green-500/30 rounded-lg bg-green-500/5">
|
||||
<h3 className="font-semibold text-foreground mb-2">Rate Limits</h3>
|
||||
<ul className="text-sm text-foreground-secondary list-disc list-inside space-y-1">
|
||||
<li>FREE tier: 100 requests/hour</li>
|
||||
<li>PRO tier: 1,000 requests/hour</li>
|
||||
<li>ENTERPRISE tier: 10,000 requests/hour</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -590,6 +616,9 @@ export default function APIDocsPage(): React.ReactElement {
|
|||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<h4 className="font-semibold text-foreground mb-2">Request Headers</h4>
|
||||
<code className="text-sm text-foreground-secondary block">
|
||||
Authorization: Bearer tpmjs_sk_your_api_key_here
|
||||
</code>
|
||||
<code className="text-sm text-foreground-secondary block mt-1">
|
||||
Content-Type: application/json
|
||||
</code>
|
||||
</div>
|
||||
|
|
@ -725,6 +754,7 @@ export default function APIDocsPage(): React.ReactElement {
|
|||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl -X POST "https://tpmjs.com/api/mcp/ajax/ajax-collection-tbc/http" \\
|
||||
-H "Authorization: Bearer tpmjs_sk_your_api_key_here" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
|
|
@ -813,7 +843,10 @@ export default function APIDocsPage(): React.ReactElement {
|
|||
'https://tpmjs.com/api/tools/execute/@tpmjs/hello/helloWorldTool',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Authorization': 'Bearer tpmjs_sk_your_api_key_here',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ prompt: 'Say hello' }),
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ const navItems: NavItem[] = [
|
|||
{ href: '/dashboard', label: 'Overview', icon: 'home' },
|
||||
{ href: '/dashboard/agents', label: 'Agents', icon: 'terminal' },
|
||||
{ href: '/dashboard/collections', label: 'Collections', icon: 'folder' },
|
||||
{ href: '/dashboard/settings/api-keys', label: 'API Keys', icon: 'key' },
|
||||
{ href: '/dashboard/usage', label: 'Usage', icon: 'globe' },
|
||||
{ href: '/dashboard/settings/tpmjs-api-keys', label: 'TPMJS API Keys', icon: 'key' },
|
||||
{ href: '/dashboard/settings/api-keys', label: 'Provider Keys', icon: 'edit' },
|
||||
{ href: '/dashboard/settings/bridge', label: 'Bridge', icon: 'link' },
|
||||
];
|
||||
|
||||
|
|
|
|||
121
apps/web/src/lib/api-keys/index.ts
Normal file
121
apps/web/src/lib/api-keys/index.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* API Key Utilities for TPMJS
|
||||
*
|
||||
* Keys are prefixed with 'tpmjs_sk_' for easy identification in logs/configs.
|
||||
* We only store SHA-256 hashes - the raw key is shown once at creation.
|
||||
*/
|
||||
|
||||
const API_KEY_PREFIX = 'tpmjs_sk_';
|
||||
const RANDOM_BYTES_LENGTH = 32;
|
||||
|
||||
export interface GeneratedApiKey {
|
||||
/** The raw API key (only shown once at creation) */
|
||||
rawKey: string;
|
||||
/** SHA-256 hash for storage and lookup */
|
||||
keyHash: string;
|
||||
/** First 16 characters for display (e.g., "tpmjs_sk_abc123...") */
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new TPMJS API key
|
||||
*
|
||||
* @returns Object containing rawKey (show once), keyHash (for storage), keyPrefix (for display)
|
||||
*
|
||||
* @example
|
||||
* const { rawKey, keyHash, keyPrefix } = generateApiKey();
|
||||
* // rawKey: "tpmjs_sk_abc123..." (show to user once)
|
||||
* // keyHash: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
|
||||
* // keyPrefix: "tpmjs_sk_abc123..."
|
||||
*/
|
||||
export function generateApiKey(): GeneratedApiKey {
|
||||
const randomPart = randomBytes(RANDOM_BYTES_LENGTH).toString('base64url');
|
||||
const rawKey = `${API_KEY_PREFIX}${randomPart}`;
|
||||
const keyHash = hashApiKey(rawKey);
|
||||
const keyPrefix = rawKey.substring(0, 16);
|
||||
|
||||
return { rawKey, keyHash, keyPrefix };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes an API key using SHA-256 for storage and lookup
|
||||
*
|
||||
* We use hashing instead of encryption because:
|
||||
* 1. We never need to recover the original key
|
||||
* 2. Users can generate new keys if lost
|
||||
* 3. Simpler and more secure (no encryption key to manage)
|
||||
*
|
||||
* @param rawKey - The raw API key to hash
|
||||
* @returns SHA-256 hash as hex string (64 characters)
|
||||
*/
|
||||
export function hashApiKey(rawKey: string): string {
|
||||
return createHash('sha256').update(rawKey).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates API key format
|
||||
*
|
||||
* @param key - The key to validate
|
||||
* @returns True if the key has valid format
|
||||
*/
|
||||
export function isValidApiKeyFormat(key: string): boolean {
|
||||
// Must start with prefix and be at least 40 chars (prefix + some random bytes)
|
||||
return key.startsWith(API_KEY_PREFIX) && key.length >= 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];
|
||||
}
|
||||
225
apps/web/src/lib/api-keys/middleware.ts
Normal file
225
apps/web/src/lib/api-keys/middleware.ts
Normal file
|
|
@ -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<AuthResult> {
|
||||
// 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 <api_key>',
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
208
apps/web/src/lib/api-keys/rate-limit.ts
Normal file
208
apps/web/src/lib/api-keys/rate-limit.ts
Normal file
|
|
@ -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<string, { count: number; windowStart: number }>();
|
||||
|
||||
/**
|
||||
* 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<RateLimitResult> {
|
||||
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<RateLimitResult> {
|
||||
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<string, string> {
|
||||
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),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
272
apps/web/src/lib/api-keys/usage.ts
Normal file
272
apps/web/src/lib/api-keys/usage.ts
Normal file
|
|
@ -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<void> {
|
||||
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<UsageEvent, 'userId' | 'apiKeyId'> & {
|
||||
userId?: string;
|
||||
apiKeyId?: string;
|
||||
}
|
||||
) {
|
||||
trackUsage({
|
||||
...event,
|
||||
userId: event.userId || userId,
|
||||
apiKeyId: event.apiKeyId || apiKeyId,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Track completion with automatic latency calculation
|
||||
*/
|
||||
trackCompletion(
|
||||
event: Omit<UsageEvent, 'userId' | 'apiKeyId' | 'latencyMs'> & {
|
||||
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<number> {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - daysToKeep);
|
||||
|
||||
const result = await prisma.apiUsageRecord.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: cutoff,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue