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
|
|
@ -217,7 +217,11 @@ Your tool will be automatically discovered through:
|
||||||
|
|
||||||
After publishing, your tool should appear on https://tpmjs.com within 15 minutes!
|
After publishing, your tool should appear on https://tpmjs.com within 15 minutes!
|
||||||
|
|
||||||
You can verify by searching: https://tpmjs.com/api/tools?q=yourpackagename
|
You can verify by searching (requires API key):
|
||||||
|
```bash
|
||||||
|
curl "https://tpmjs.com/api/tools?q=yourpackagename" \
|
||||||
|
-H "Authorization: Bearer tpmjs_sk_your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
## Real Example: @tpmjs/createblogpost
|
## Real Example: @tpmjs/createblogpost
|
||||||
|
|
||||||
|
|
@ -411,14 +415,15 @@ Or manually check the structure matches the examples above.
|
||||||
- Add all Rich tier fields for maximum visibility
|
- Add all Rich tier fields for maximum visibility
|
||||||
|
|
||||||
**Want to force a sync?**
|
**Want to force a sync?**
|
||||||
You can manually trigger a sync (requires auth):
|
You can manually trigger a sync (requires CRON_SECRET, not a user API key):
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "https://tpmjs.com/api/sync/keyword" \
|
curl -X POST "https://tpmjs.com/api/sync/keyword" \
|
||||||
-H "Authorization: Bearer YOUR_CRON_SECRET"
|
-H "Authorization: Bearer $CRON_SECRET"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
Questions or issues?
|
Questions or issues?
|
||||||
- File an issue: https://github.com/ajaxdavis/tpmjs/issues
|
- File an issue: https://github.com/ajaxdavis/tpmjs/issues
|
||||||
- Check the API: https://tpmjs.com/api/tools
|
- Check the API docs: https://tpmjs.com/docs/api
|
||||||
|
- Generate an API key: https://tpmjs.com/dashboard/settings/tpmjs-api-keys
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@
|
||||||
* GET: Retrieve conversation history
|
* GET: Retrieve conversation history
|
||||||
* DELETE: Delete a conversation
|
* 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';
|
import { Prisma, prisma } from '@tpmjs/db';
|
||||||
|
|
@ -14,6 +17,8 @@ import { SendMessageSchema } from '@tpmjs/types/agent';
|
||||||
import type { LanguageModel, ModelMessage } from 'ai';
|
import type { LanguageModel, ModelMessage } from 'ai';
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { decryptApiKey } from '@/lib/crypto/api-keys';
|
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';
|
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
|
* Send a message and stream the AI response via SSE
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
|
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);
|
const rateLimitResponse = checkRateLimit(request, CHAT_RATE_LIMIT);
|
||||||
if (rateLimitResponse) {
|
if (rateLimitResponse) {
|
||||||
return rateLimitResponse;
|
return rateLimitResponse;
|
||||||
|
|
@ -439,6 +463,23 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
executionTimeMs,
|
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) {
|
} catch (error) {
|
||||||
// Log detailed error for debugging
|
// Log detailed error for debugging
|
||||||
console.error('[Agent] Conversation stream error:', {
|
console.error('[Agent] Conversation stream error:', {
|
||||||
|
|
@ -450,6 +491,22 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
||||||
sendEvent('error', {
|
sendEvent('error', {
|
||||||
message: error instanceof Error ? error.message : 'Unknown 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 {
|
} finally {
|
||||||
controller.close();
|
controller.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { prisma } from '@tpmjs/db';
|
import { prisma } from '@tpmjs/db';
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
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 runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridge Registration & Status API
|
* Bridge Registration & Status API
|
||||||
|
|
@ -10,30 +13,37 @@ export const dynamic = 'force-dynamic';
|
||||||
* POST: Register bridge tools
|
* POST: Register bridge tools
|
||||||
* GET: Get bridge status and pending tool calls
|
* GET: Get bridge status and pending tool calls
|
||||||
* DELETE: Disconnect bridge
|
* 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
|
// POST: Register bridge and its tools
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
const startTime = Date.now();
|
||||||
const authHeader = request.headers.get('authorization');
|
let authResult: Awaited<ReturnType<typeof authenticateRequest>> | null = null;
|
||||||
const token = authHeader?.replace('Bearer ', '');
|
|
||||||
|
|
||||||
const user = await validateApiKey(token);
|
try {
|
||||||
if (!user) {
|
// 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 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,7 +53,7 @@ export async function POST(request: NextRequest) {
|
||||||
if (type === 'register') {
|
if (type === 'register') {
|
||||||
// Register bridge and tools
|
// Register bridge and tools
|
||||||
await prisma.bridgeConnection.upsert({
|
await prisma.bridgeConnection.upsert({
|
||||||
where: { userId: user.id },
|
where: { userId },
|
||||||
update: {
|
update: {
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
tools: tools || [],
|
tools: tools || [],
|
||||||
|
|
@ -52,7 +62,7 @@ export async function POST(request: NextRequest) {
|
||||||
clientOS: body.clientOS,
|
clientOS: body.clientOS,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
userId: user.id,
|
userId,
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
tools: tools || [],
|
tools: tools || [],
|
||||||
lastSeen: new Date(),
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Registered ${tools?.length || 0} tools`,
|
message: `Registered ${tools?.length || 0} tools`,
|
||||||
|
|
@ -80,7 +101,7 @@ export async function POST(request: NextRequest) {
|
||||||
if (type === 'heartbeat') {
|
if (type === 'heartbeat') {
|
||||||
// Update last seen
|
// Update last seen
|
||||||
await prisma.bridgeConnection.update({
|
await prisma.bridgeConnection.update({
|
||||||
where: { userId: user.id },
|
where: { userId },
|
||||||
data: { lastSeen: new Date() },
|
data: { lastSeen: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -90,6 +111,22 @@ export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Bridge POST error:', 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(
|
return NextResponse.json(
|
||||||
{ error: error instanceof Error ? error.message : 'Internal error' },
|
{ error: error instanceof Error ? error.message : 'Internal error' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|
@ -98,19 +135,31 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: Get pending tool calls (polling)
|
// GET: Get pending tool calls (polling)
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(_request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const authHeader = request.headers.get('authorization');
|
// Authenticate request (supports both session and API key)
|
||||||
const token = authHeader?.replace('Bearer ', '');
|
const authResult = await authenticateRequest();
|
||||||
|
|
||||||
const user = await validateApiKey(token);
|
if (!authResult.authenticated) {
|
||||||
if (!user) {
|
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 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get pending tool calls for this user
|
// Get pending tool calls for this user
|
||||||
const pendingCalls = Array.from(pendingToolCalls.entries())
|
const pendingCalls = Array.from(pendingToolCalls.entries())
|
||||||
.filter(([key]) => key.startsWith(`${user.id}:`))
|
.filter(([key]) => key.startsWith(`${userId}:`))
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
pendingToolCalls.delete(key); // Remove after returning
|
pendingToolCalls.delete(key); // Remove after returning
|
||||||
return value;
|
return value;
|
||||||
|
|
@ -118,7 +167,7 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
// Update last seen
|
// Update last seen
|
||||||
await prisma.bridgeConnection.update({
|
await prisma.bridgeConnection.update({
|
||||||
where: { userId: user.id },
|
where: { userId },
|
||||||
data: { lastSeen: new Date() },
|
data: { lastSeen: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -136,18 +185,30 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: Disconnect bridge
|
// DELETE: Disconnect bridge
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(_request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const authHeader = request.headers.get('authorization');
|
// Authenticate request (supports both session and API key)
|
||||||
const token = authHeader?.replace('Bearer ', '');
|
const authResult = await authenticateRequest();
|
||||||
|
|
||||||
const user = await validateApiKey(token);
|
if (!authResult.authenticated) {
|
||||||
if (!user) {
|
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 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.bridgeConnection.update({
|
await prisma.bridgeConnection.update({
|
||||||
where: { userId: user.id },
|
where: { userId },
|
||||||
data: { status: 'disconnected' },
|
data: { status: 'disconnected' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { prisma } from '@tpmjs/db';
|
import { prisma } from '@tpmjs/db';
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
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';
|
import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/handlers';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
@ -9,6 +17,9 @@ export const maxDuration = 60;
|
||||||
|
|
||||||
const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries
|
const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries
|
||||||
|
|
||||||
|
// Authentication is required for all MCP operations
|
||||||
|
const REQUIRE_AUTH = true;
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
params: Promise<{ username: string; slug: string; transport: string }>;
|
params: Promise<{ username: string; slug: string; transport: string }>;
|
||||||
}
|
}
|
||||||
|
|
@ -205,6 +216,9 @@ function handleSseGet(
|
||||||
* MCP JSON-RPC endpoint for tool execution
|
* MCP JSON-RPC endpoint for tool execution
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
|
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let authResult: Awaited<ReturnType<typeof authenticateRequest>> | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { username, slug, transport } = await context.params;
|
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);
|
const collection = await getPublicCollectionByUsernameAndSlug(username, slug);
|
||||||
|
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
|
|
@ -228,14 +289,66 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
if (transport === 'sse') {
|
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) {
|
} catch (error) {
|
||||||
console.error('[MCP POST] Error:', error);
|
console.error('[MCP POST] Error:', error);
|
||||||
const message = error instanceof Error ? error.message : 'Internal server 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(
|
return NextResponse.json(
|
||||||
{ jsonrpc: '2.0', error: { code: -32603, message }, id: null },
|
{ jsonrpc: '2.0', error: { code: -32603, message }, id: null },
|
||||||
{ status: 500 }
|
{ 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">
|
<DocSection id="quick-start" title="Quick Start">
|
||||||
<p className="text-foreground-secondary mb-6">
|
<p className="text-foreground-secondary mb-6">
|
||||||
Try these examples to get started immediately. All public endpoints work without
|
Try these examples to get started. All API endpoints require authentication via API
|
||||||
authentication.
|
key. Generate one from <strong>Settings → TPMJS API Keys</strong> in your dashboard.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<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>
|
<h3 className="text-lg font-semibold text-foreground mb-3">1. List Tools</h3>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
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>
|
</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>
|
<h3 className="text-lg font-semibold text-foreground mb-3">2. Search Tools</h3>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -335,7 +337,8 @@ export default function APIDocsPage(): React.ReactElement {
|
||||||
</h3>
|
</h3>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -346,6 +349,7 @@ export default function APIDocsPage(): React.ReactElement {
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`curl -X POST "https://tpmjs.com/api/mcp/ajax/ajax-collection-tbc/http" \\
|
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" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq`}
|
-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">
|
<DocSection id="authentication" title="Authentication">
|
||||||
<p className="text-foreground-secondary mb-6">
|
<p className="text-foreground-secondary mb-6">
|
||||||
Most public endpoints don't require authentication. Private endpoints (creating
|
All API endpoints require authentication via TPMJS API keys. Generate an API key
|
||||||
collections, managing agents) require a session cookie from signing in.
|
from your dashboard at <strong>Settings → TPMJS API Keys</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 mb-6">
|
||||||
<div className="p-4 border border-green-500/30 rounded-lg bg-green-500/5">
|
<div className="p-4 border border-primary/30 rounded-lg bg-primary/5">
|
||||||
<h3 className="font-semibold text-foreground mb-2">Public (No Auth)</h3>
|
<h3 className="font-semibold text-foreground mb-2">API Key Format</h3>
|
||||||
<ul className="text-sm text-foreground-secondary list-disc list-inside space-y-1">
|
<p className="text-sm text-foreground-secondary mb-2">
|
||||||
<li>GET /api/tools - List and search tools</li>
|
API keys use the <code className="text-primary">tpmjs_sk_</code> prefix and are
|
||||||
<li>GET /api/public/collections - List public collections</li>
|
passed in the Authorization header:
|
||||||
<li>GET /api/public/agents - List public agents</li>
|
</p>
|
||||||
<li>POST /api/mcp/[user]/[slug]/http - MCP protocol for public collections</li>
|
<CodeBlock
|
||||||
<li>GET /api/stats - Platform statistics</li>
|
language="bash"
|
||||||
</ul>
|
code={`curl "https://tpmjs.com/api/tools" \\
|
||||||
|
-H "Authorization: Bearer tpmjs_sk_your_api_key_here"`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border border-yellow-500/30 rounded-lg bg-yellow-500/5">
|
<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">
|
<ul className="text-sm text-foreground-secondary list-disc list-inside space-y-1">
|
||||||
<li>POST /api/collections - Create collection</li>
|
<li>
|
||||||
<li>POST /api/agents - Create agent</li>
|
<code className="text-primary">mcp:execute</code> - MCP tool execution
|
||||||
<li>PUT /api/collections/[id] - Update collection</li>
|
</li>
|
||||||
<li>DELETE /api/agents/[id] - Delete agent</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -590,6 +616,9 @@ export default function APIDocsPage(): React.ReactElement {
|
||||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||||
<h4 className="font-semibold text-foreground mb-2">Request Headers</h4>
|
<h4 className="font-semibold text-foreground mb-2">Request Headers</h4>
|
||||||
<code className="text-sm text-foreground-secondary block">
|
<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
|
Content-Type: application/json
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -725,6 +754,7 @@ export default function APIDocsPage(): React.ReactElement {
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`curl -X POST "https://tpmjs.com/api/mcp/ajax/ajax-collection-tbc/http" \\
|
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" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{
|
-d '{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
|
|
@ -813,7 +843,10 @@ export default function APIDocsPage(): React.ReactElement {
|
||||||
'https://tpmjs.com/api/tools/execute/@tpmjs/hello/helloWorldTool',
|
'https://tpmjs.com/api/tools/execute/@tpmjs/hello/helloWorldTool',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
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' }),
|
body: JSON.stringify({ prompt: 'Say hello' }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ const navItems: NavItem[] = [
|
||||||
{ href: '/dashboard', label: 'Overview', icon: 'home' },
|
{ href: '/dashboard', label: 'Overview', icon: 'home' },
|
||||||
{ href: '/dashboard/agents', label: 'Agents', icon: 'terminal' },
|
{ href: '/dashboard/agents', label: 'Agents', icon: 'terminal' },
|
||||||
{ href: '/dashboard/collections', label: 'Collections', icon: 'folder' },
|
{ 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' },
|
{ 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;
|
||||||
|
}
|
||||||
|
|
@ -401,9 +401,9 @@ class TPMJSBridge {
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Connect to TPMJS WebSocket
|
// 3. Connect to TPMJS WebSocket (requires API key with bridge:connect scope)
|
||||||
this.ws = new WebSocket(
|
this.ws = new WebSocket(
|
||||||
`${this.config.tpmjsUrl}/api/bridge?token=${this.config.apiKey}`
|
`${this.config.tpmjsUrl}/api/bridge?token=${this.config.apiKey}` // apiKey format: tpmjs_sk_...
|
||||||
);
|
);
|
||||||
|
|
||||||
this.ws.on('open', () => {
|
this.ws.on('open', () => {
|
||||||
|
|
@ -443,6 +443,7 @@ class TPMJSBridge {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLI entry point
|
// CLI entry point
|
||||||
|
// API key is loaded from ~/.tpmjs/credentials.json (format: tpmjs_sk_...)
|
||||||
const config = loadConfig(); // from ~/.tpmjs/bridge.json
|
const config = loadConfig(); // from ~/.tpmjs/bridge.json
|
||||||
const bridge = new TPMJSBridge(config);
|
const bridge = new TPMJSBridge(config);
|
||||||
bridge.start();
|
bridge.start();
|
||||||
|
|
@ -452,9 +453,12 @@ bridge.start();
|
||||||
|
|
||||||
Server-side handler for bridge connections.
|
Server-side handler for bridge connections.
|
||||||
|
|
||||||
|
**Authentication:** Requires TPMJS API key (format: `tpmjs_sk_...`) with `bridge:connect` scope.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// apps/web/src/app/api/bridge/route.ts
|
// apps/web/src/app/api/bridge/route.ts
|
||||||
import { prisma } from '@tpmjs/db';
|
import { prisma } from '@tpmjs/db';
|
||||||
|
import { authenticateRequest, hasScope } from '~/lib/api-keys/middleware';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
|
@ -463,9 +467,9 @@ export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
// Validate API key
|
// Validate API key (must have bridge:connect scope)
|
||||||
const user = await validateApiKey(token);
|
const authResult = await authenticateRequest();
|
||||||
if (!user) {
|
if (!authResult.authenticated || !hasScope(authResult, 'bridge:connect')) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -715,11 +719,19 @@ interface ToolExecutionError {
|
||||||
|
|
||||||
### Security Considerations
|
### Security Considerations
|
||||||
|
|
||||||
1. **API Key Authentication**: Bridge connections require valid API key
|
1. **API Key Authentication**: All API endpoints require a valid TPMJS API key (`tpmjs_sk_...` prefix)
|
||||||
2. **User Isolation**: Each user's bridge is isolated
|
2. **Scope-Based Access**: API keys have specific scopes (e.g., `bridge:connect`, `mcp:execute`, `agent:chat`)
|
||||||
3. **Tool Whitelisting**: Users explicitly add tools to collections
|
3. **User Isolation**: Each user's bridge is isolated
|
||||||
4. **Encrypted Credentials**: Remote MCP server credentials encrypted at rest
|
4. **Tool Whitelisting**: Users explicitly add tools to collections
|
||||||
5. **WebSocket Security**: WSS (TLS) required for bridge connections
|
5. **Encrypted Credentials**: Remote MCP server credentials encrypted at rest
|
||||||
|
6. **WebSocket Security**: WSS (TLS) required for bridge connections
|
||||||
|
|
||||||
|
**Required API Key Scopes:**
|
||||||
|
- `bridge:connect` - For bridge WebSocket connections
|
||||||
|
- `mcp:execute` - For MCP tool execution
|
||||||
|
- `collection:read` - For accessing collection data
|
||||||
|
|
||||||
|
Generate API keys from Settings > TPMJS API Keys in the dashboard.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -819,12 +831,17 @@ After setup, user only needs ONE MCP server in their config:
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"tpmjs": {
|
"tpmjs": {
|
||||||
"type": "url",
|
"type": "url",
|
||||||
"url": "https://tpmjs.com/api/mcp/username/all-my-tools/http"
|
"url": "https://tpmjs.com/api/mcp/username/all-my-tools/http",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer tpmjs_sk_your_api_key_here"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** Generate your API key from Settings > TPMJS API Keys. The key requires `mcp:execute` scope.
|
||||||
|
|
||||||
This single endpoint provides access to:
|
This single endpoint provides access to:
|
||||||
- All npm tools in the collection
|
- All npm tools in the collection
|
||||||
- All remote MCP tools configured
|
- All remote MCP tools configured
|
||||||
|
|
|
||||||
|
|
@ -648,7 +648,7 @@ npx @tpmjs/bridge <command>
|
||||||
**~/.tpmjs/credentials.json**
|
**~/.tpmjs/credentials.json**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"apiKey": "tpmjs_xxxxxxxxxxxxxxxxxxxx",
|
"apiKey": "tpmjs_sk_xxxxxxxxxxxxxxxxxxxx",
|
||||||
"userId": "user_abc123",
|
"userId": "user_abc123",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
"expiresAt": "2026-01-12T00:00:00Z"
|
"expiresAt": "2026-01-12T00:00:00Z"
|
||||||
|
|
@ -703,9 +703,11 @@ await manager.disconnect('chrome');
|
||||||
#### Connection
|
#### Connection
|
||||||
|
|
||||||
```
|
```
|
||||||
wss://tpmjs.com/api/bridge?token=tpmjs_xxxx
|
wss://tpmjs.com/api/bridge?token=tpmjs_sk_your_api_key_here
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** All TPMJS API endpoints require authentication. Generate an API key from your dashboard at Settings > TPMJS API Keys. API keys use the `tpmjs_sk_` prefix.
|
||||||
|
|
||||||
#### Messages: Bridge → TPMJS
|
#### Messages: Bridge → TPMJS
|
||||||
|
|
||||||
**Register Tools**
|
**Register Tools**
|
||||||
|
|
@ -907,16 +909,28 @@ ALTER TABLE "CollectionBridgeTool" ADD CONSTRAINT "CollectionBridgeTool_collecti
|
||||||
**Endpoint**: `GET /api/bridge`
|
**Endpoint**: `GET /api/bridge`
|
||||||
|
|
||||||
**Query Parameters**:
|
**Query Parameters**:
|
||||||
- `token` (required): User's API key
|
- `token` (required): User's TPMJS API key (format: `tpmjs_sk_...`)
|
||||||
|
|
||||||
**Upgrade**: WebSocket
|
**Upgrade**: WebSocket
|
||||||
|
|
||||||
**Authentication**: Validates API key, returns 401 if invalid
|
**Authentication**: Validates API key with `bridge:connect` scope, returns 401 if invalid
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```bash
|
||||||
|
# Connect via WebSocket with API key
|
||||||
|
wscat -c 'wss://tpmjs.com/api/bridge?token=tpmjs_sk_your_api_key_here'
|
||||||
|
```
|
||||||
|
|
||||||
### Bridge Status API
|
### Bridge Status API
|
||||||
|
|
||||||
**Endpoint**: `GET /api/user/bridge`
|
**Endpoint**: `GET /api/user/bridge`
|
||||||
|
|
||||||
|
**Authentication**: Requires API key with `bridge:connect` scope
|
||||||
|
```bash
|
||||||
|
curl https://tpmjs.com/api/user/bridge \
|
||||||
|
-H 'Authorization: Bearer tpmjs_sk_your_api_key_here'
|
||||||
|
```
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -945,13 +959,18 @@ ALTER TABLE "CollectionBridgeTool" ADD CONSTRAINT "CollectionBridgeTool_collecti
|
||||||
|
|
||||||
### Collection Bridge Tools API
|
### Collection Bridge Tools API
|
||||||
|
|
||||||
|
All collection endpoints require API key with `collection:read` scope.
|
||||||
|
|
||||||
**Add Tool**: `POST /api/collections/{id}/bridge-tools`
|
**Add Tool**: `POST /api/collections/{id}/bridge-tools`
|
||||||
|
|
||||||
```json
|
```bash
|
||||||
{
|
curl -X POST 'https://tpmjs.com/api/collections/{id}/bridge-tools' \
|
||||||
|
-H 'Authorization: Bearer tpmjs_sk_your_api_key_here' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
"serverId": "chrome-devtools",
|
"serverId": "chrome-devtools",
|
||||||
"toolName": "screenshot"
|
"toolName": "screenshot"
|
||||||
}
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Remove Tool**: `DELETE /api/collections/{id}/bridge-tools/{toolId}`
|
**Remove Tool**: `DELETE /api/collections/{id}/bridge-tools/{toolId}`
|
||||||
|
|
|
||||||
|
|
@ -159,8 +159,9 @@ This happened with the `startTime is not defined` bug. Our executor code had a b
|
||||||
### Investigating a Specific Tool
|
### Investigating a Specific Tool
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check current health status
|
# Check current health status (requires API key)
|
||||||
curl -s 'https://tpmjs.com/api/tools?limit=50' | \
|
curl -s 'https://tpmjs.com/api/tools?limit=50' \
|
||||||
|
-H 'Authorization: Bearer tpmjs_sk_your_api_key_here' | \
|
||||||
jq '.data[] | select(.package.npmPackageName == "PACKAGE_NAME") | {
|
jq '.data[] | select(.package.npmPackageName == "PACKAGE_NAME") | {
|
||||||
packageName: .package.npmPackageName,
|
packageName: .package.npmPackageName,
|
||||||
exportName: .exportName,
|
exportName: .exportName,
|
||||||
|
|
@ -179,10 +180,11 @@ cat package/dist/index.js
|
||||||
|
|
||||||
### Manually Updating Health Status
|
### Manually Updating Health Status
|
||||||
|
|
||||||
For testing or correction:
|
For testing or correction (requires API key with appropriate scope):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST 'https://tpmjs.com/api/tools/report-health' \
|
curl -X POST 'https://tpmjs.com/api/tools/report-health' \
|
||||||
|
-H 'Authorization: Bearer tpmjs_sk_your_api_key_here' \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"packageName": "@scope/package",
|
"packageName": "@scope/package",
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,9 @@ model User {
|
||||||
image String?
|
image String?
|
||||||
username String? @unique @db.VarChar(30) // URL-friendly username (nullable for migration)
|
username String? @unique @db.VarChar(30) // URL-friendly username (nullable for migration)
|
||||||
|
|
||||||
|
// Tier for rate limiting and feature access
|
||||||
|
tier UserTier @default(FREE)
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
|
@ -347,6 +350,8 @@ model User {
|
||||||
agentLikes AgentLike[]
|
agentLikes AgentLike[]
|
||||||
activities UserActivity[]
|
activities UserActivity[]
|
||||||
bridgeConnection BridgeConnection?
|
bridgeConnection BridgeConnection?
|
||||||
|
tpmjsApiKeys TpmjsApiKey[] @relation("UserTpmjsApiKeys")
|
||||||
|
usageSummaries ApiUsageSummary[] @relation("UserUsageSummaries")
|
||||||
|
|
||||||
@@index([username])
|
@@index([username])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
|
|
@ -886,3 +891,137 @@ model CollectionBridgeTool {
|
||||||
@@index([collectionId])
|
@@index([collectionId])
|
||||||
@@map("collection_bridge_tools")
|
@@map("collection_bridge_tools")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Key & Usage Tracking Models
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// User tier enum - determines rate limits and feature access
|
||||||
|
enum UserTier {
|
||||||
|
FREE
|
||||||
|
PRO
|
||||||
|
ENTERPRISE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TpmjsApiKey - user-owned API keys for programmatic access
|
||||||
|
model TpmjsApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// Owner relationship
|
||||||
|
userId String @map("user_id")
|
||||||
|
user User @relation("UserTpmjsApiKeys", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Key identification
|
||||||
|
name String @db.VarChar(100) // User-provided name (e.g., "Production Server")
|
||||||
|
keyHash String @unique @map("key_hash") @db.VarChar(64) // SHA-256 hash (never store raw keys)
|
||||||
|
keyPrefix String @map("key_prefix") @db.VarChar(20) // First 16 chars for identification (tpmjs_sk_abc123...)
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
scopes String[] @default([]) // ["mcp:execute", "agent:chat", "bridge:connect", "usage:read"]
|
||||||
|
|
||||||
|
// Rate limiting (overrides tier default if set)
|
||||||
|
rateLimit Int? @map("rate_limit") // Requests per hour (null = use tier default)
|
||||||
|
|
||||||
|
// Status
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
lastUsedAt DateTime? @map("last_used_at")
|
||||||
|
expiresAt DateTime? @map("expires_at")
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
usageRecords ApiUsageRecord[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([keyHash])
|
||||||
|
@@index([keyPrefix])
|
||||||
|
@@index([isActive])
|
||||||
|
@@map("tpmjs_api_keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ApiUsageRecord - individual API request logs (kept for 30 days)
|
||||||
|
model ApiUsageRecord {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// API key relationship
|
||||||
|
apiKeyId String @map("api_key_id")
|
||||||
|
apiKey TpmjsApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Request details
|
||||||
|
endpoint String @db.VarChar(500)
|
||||||
|
method String @db.VarChar(10) // GET, POST, etc.
|
||||||
|
statusCode Int @map("status_code")
|
||||||
|
latencyMs Int @map("latency_ms")
|
||||||
|
|
||||||
|
// Resource tracking
|
||||||
|
resourceType String? @map("resource_type") @db.VarChar(50) // "mcp" | "agent" | "bridge" | "collection"
|
||||||
|
resourceId String? @map("resource_id") @db.VarChar(100) // Collection ID, Agent ID, etc.
|
||||||
|
|
||||||
|
// LLM usage (if applicable)
|
||||||
|
tokensIn Int? @map("tokens_in")
|
||||||
|
tokensOut Int? @map("tokens_out")
|
||||||
|
model String? @db.VarChar(50) // e.g., "gpt-4o-mini"
|
||||||
|
|
||||||
|
// Error tracking
|
||||||
|
errorCode String? @map("error_code") @db.VarChar(50)
|
||||||
|
errorMessage String? @map("error_message") @db.Text
|
||||||
|
|
||||||
|
// Client metadata
|
||||||
|
userAgent String? @map("user_agent") @db.VarChar(500)
|
||||||
|
ipAddress String? @map("ip_address") @db.VarChar(45) // IPv4 or IPv6
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([apiKeyId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([endpoint])
|
||||||
|
@@index([resourceType, resourceId])
|
||||||
|
@@map("api_usage_records")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ApiUsageSummary - aggregated usage summaries (hourly/daily rollups)
|
||||||
|
model ApiUsageSummary {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// User relationship
|
||||||
|
userId String @map("user_id")
|
||||||
|
user User @relation("UserUsageSummaries", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Optional API key (null for user-level summaries)
|
||||||
|
apiKeyId String? @map("api_key_id")
|
||||||
|
|
||||||
|
// Time period
|
||||||
|
periodType String @map("period_type") @db.VarChar(20) // "hourly" | "daily" | "monthly"
|
||||||
|
periodStart DateTime @map("period_start")
|
||||||
|
|
||||||
|
// Request counts
|
||||||
|
totalRequests Int @default(0) @map("total_requests")
|
||||||
|
successRequests Int @default(0) @map("success_requests")
|
||||||
|
errorRequests Int @default(0) @map("error_requests")
|
||||||
|
|
||||||
|
// Endpoint breakdown (JSON: { "/api/mcp/...": 100, ... })
|
||||||
|
endpointCounts Json @default("{}") @map("endpoint_counts") @db.JsonB
|
||||||
|
|
||||||
|
// LLM usage totals
|
||||||
|
totalTokensIn Int @default(0) @map("total_tokens_in")
|
||||||
|
totalTokensOut Int @default(0) @map("total_tokens_out")
|
||||||
|
|
||||||
|
// Performance
|
||||||
|
avgLatencyMs Float @default(0) @map("avg_latency_ms")
|
||||||
|
p95LatencyMs Float @default(0) @map("p95_latency_ms")
|
||||||
|
|
||||||
|
// Cost estimation (in cents)
|
||||||
|
estimatedCostCents Int @default(0) @map("estimated_cost_cents")
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([userId, apiKeyId, periodType, periodStart])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([periodType, periodStart])
|
||||||
|
@@map("api_usage_summaries")
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue