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:
Ajax Davis 2026-01-13 04:45:52 +10:00
parent b663ca3e05
commit a3f1f3935e
20 changed files with 2813 additions and 90 deletions

View file

@ -217,7 +217,11 @@ Your tool will be automatically discovered through:
After publishing, your tool should appear on https://tpmjs.com within 15 minutes!
You can verify by searching: https://tpmjs.com/api/tools?q=yourpackagename
You can verify by searching (requires API key):
```bash
curl "https://tpmjs.com/api/tools?q=yourpackagename" \
-H "Authorization: Bearer tpmjs_sk_your_api_key_here"
```
## Real Example: @tpmjs/createblogpost
@ -411,14 +415,15 @@ Or manually check the structure matches the examples above.
- Add all Rich tier fields for maximum visibility
**Want to force a sync?**
You can manually trigger a sync (requires auth):
You can manually trigger a sync (requires CRON_SECRET, not a user API key):
```bash
curl -X POST "https://tpmjs.com/api/sync/keyword" \
-H "Authorization: Bearer YOUR_CRON_SECRET"
-H "Authorization: Bearer $CRON_SECRET"
```
## Support
Questions or issues?
- File an issue: https://github.com/ajaxdavis/tpmjs/issues
- Check the API: https://tpmjs.com/api/tools
- Check the API docs: https://tpmjs.com/docs/api
- Generate an API key: https://tpmjs.com/dashboard/settings/tpmjs-api-keys

View file

@ -5,7 +5,10 @@
* GET: Retrieve conversation history
* DELETE: Delete a conversation
*
* This endpoint uses agent id directly for dashboard usage
* This endpoint uses agent id directly for dashboard usage.
*
* Authentication: Supports both session auth and TPMJS API key auth.
* Requires 'agent:chat' scope for API key access.
*/
import { Prisma, prisma } from '@tpmjs/db';
@ -14,6 +17,8 @@ import { SendMessageSchema } from '@tpmjs/types/agent';
import type { LanguageModel, ModelMessage } from 'ai';
import { type NextRequest, NextResponse } from 'next/server';
import { decryptApiKey } from '@/lib/crypto/api-keys';
import { authenticateRequest, hasScope } from '~/lib/api-keys/middleware';
import { trackUsage } from '~/lib/api-keys/usage';
import { checkRateLimit, type RateLimitConfig } from '~/lib/rate-limit';
/**
@ -72,7 +77,26 @@ async function getProviderModel(
* Send a message and stream the AI response via SSE
*/
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
// Check rate limit first to prevent expensive LLM calls
const startTime = Date.now();
// Authenticate request (supports both session and API key)
const authResult = await authenticateRequest();
if (!authResult.authenticated) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check scope for API key auth
if (authResult.authenticated && !authResult.isSessionAuth) {
if (!hasScope(authResult, 'agent:chat')) {
return NextResponse.json(
{ error: 'API key does not have agent:chat scope' },
{ status: 403 }
);
}
}
// Check rate limit (after auth so we can track by user if needed)
const rateLimitResponse = checkRateLimit(request, CHAT_RATE_LIMIT);
if (rateLimitResponse) {
return rateLimitResponse;
@ -439,6 +463,23 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
conversationId: conversation.id,
executionTimeMs,
});
// Track usage
if (authResult.userId) {
trackUsage({
apiKeyId: authResult.apiKeyId ?? undefined,
userId: authResult.userId,
endpoint: `/api/agents/${agentId}/conversation/${conversationId}`,
method: 'POST',
statusCode: 200,
latencyMs: executionTimeMs,
resourceType: 'agent',
resourceId: agentId,
tokensIn: inputTokens,
tokensOut: outputTokens,
model: agent.modelId,
});
}
} catch (error) {
// Log detailed error for debugging
console.error('[Agent] Conversation stream error:', {
@ -450,6 +491,22 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
sendEvent('error', {
message: error instanceof Error ? error.message : 'Unknown error',
});
// Track error
if (authResult.userId) {
trackUsage({
apiKeyId: authResult.apiKeyId ?? undefined,
userId: authResult.userId,
endpoint: `/api/agents/${agentId}/conversation/${conversationId}`,
method: 'POST',
statusCode: 500,
latencyMs: Date.now() - startTime,
resourceType: 'agent',
resourceId: agentId,
errorCode: 'STREAM_ERROR',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
});
}
} finally {
controller.close();
}

View file

@ -1,8 +1,11 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
import { authenticateRequest, hasScope } from '~/lib/api-keys/middleware';
import { trackUsage } from '~/lib/api-keys/usage';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
/**
* Bridge Registration & Status API
@ -10,30 +13,37 @@ export const dynamic = 'force-dynamic';
* POST: Register bridge tools
* GET: Get bridge status and pending tool calls
* DELETE: Disconnect bridge
*
* Authentication: Supports both session auth and TPMJS API key auth.
* Requires 'bridge:connect' scope for API key access.
*/
// Validate API key and get user
async function validateApiKey(token: string | null | undefined) {
if (!token) return null;
// For now, use session-based auth
// In production, you'd want proper API key validation with encrypted keys
const session = await prisma.session.findUnique({
where: { token },
include: { user: true },
});
return session?.user || null;
}
// POST: Register bridge and its tools
export async function POST(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
const startTime = Date.now();
let authResult: Awaited<ReturnType<typeof authenticateRequest>> | null = null;
const user = await validateApiKey(token);
if (!user) {
try {
// Authenticate request (supports both session and API key)
authResult = await authenticateRequest();
if (!authResult.authenticated) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check scope for API key auth
if (authResult.authenticated && !authResult.isSessionAuth) {
if (!hasScope(authResult, 'bridge:connect')) {
return NextResponse.json(
{ error: 'API key does not have bridge:connect scope' },
{ status: 403 }
);
}
}
// Get user for bridge operations
const userId = authResult.userId;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@ -43,7 +53,7 @@ export async function POST(request: NextRequest) {
if (type === 'register') {
// Register bridge and tools
await prisma.bridgeConnection.upsert({
where: { userId: user.id },
where: { userId },
update: {
status: 'connected',
tools: tools || [],
@ -52,7 +62,7 @@ export async function POST(request: NextRequest) {
clientOS: body.clientOS,
},
create: {
userId: user.id,
userId,
status: 'connected',
tools: tools || [],
lastSeen: new Date(),
@ -61,6 +71,17 @@ export async function POST(request: NextRequest) {
},
});
// Track usage
trackUsage({
apiKeyId: authResult?.apiKeyId ?? undefined,
userId,
endpoint: '/api/bridge',
method: 'POST',
statusCode: 200,
latencyMs: Date.now() - startTime,
resourceType: 'bridge',
});
return NextResponse.json({
success: true,
message: `Registered ${tools?.length || 0} tools`,
@ -80,7 +101,7 @@ export async function POST(request: NextRequest) {
if (type === 'heartbeat') {
// Update last seen
await prisma.bridgeConnection.update({
where: { userId: user.id },
where: { userId },
data: { lastSeen: new Date() },
});
@ -90,6 +111,22 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
} catch (error) {
console.error('Bridge POST error:', error);
// Track error
if (authResult?.userId) {
trackUsage({
apiKeyId: authResult?.apiKeyId ?? undefined,
userId: authResult.userId,
endpoint: '/api/bridge',
method: 'POST',
statusCode: 500,
latencyMs: Date.now() - startTime,
resourceType: 'bridge',
errorCode: 'INTERNAL_ERROR',
errorMessage: error instanceof Error ? error.message : 'Internal error',
});
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 }
@ -98,19 +135,31 @@ export async function POST(request: NextRequest) {
}
// GET: Get pending tool calls (polling)
export async function GET(request: NextRequest) {
export async function GET(_request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
// Authenticate request (supports both session and API key)
const authResult = await authenticateRequest();
const user = await validateApiKey(token);
if (!user) {
if (!authResult.authenticated) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check scope for API key auth
if (!authResult.isSessionAuth && !hasScope(authResult, 'bridge:connect')) {
return NextResponse.json(
{ error: 'API key does not have bridge:connect scope' },
{ status: 403 }
);
}
const userId = authResult.userId;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get pending tool calls for this user
const pendingCalls = Array.from(pendingToolCalls.entries())
.filter(([key]) => key.startsWith(`${user.id}:`))
.filter(([key]) => key.startsWith(`${userId}:`))
.map(([key, value]) => {
pendingToolCalls.delete(key); // Remove after returning
return value;
@ -118,7 +167,7 @@ export async function GET(request: NextRequest) {
// Update last seen
await prisma.bridgeConnection.update({
where: { userId: user.id },
where: { userId },
data: { lastSeen: new Date() },
});
@ -136,18 +185,30 @@ export async function GET(request: NextRequest) {
}
// DELETE: Disconnect bridge
export async function DELETE(request: NextRequest) {
export async function DELETE(_request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
// Authenticate request (supports both session and API key)
const authResult = await authenticateRequest();
const user = await validateApiKey(token);
if (!user) {
if (!authResult.authenticated) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check scope for API key auth
if (!authResult.isSessionAuth && !hasScope(authResult, 'bridge:connect')) {
return NextResponse.json(
{ error: 'API key does not have bridge:connect scope' },
{ status: 403 }
);
}
const userId = authResult.userId;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await prisma.bridgeConnection.update({
where: { userId: user.id },
where: { userId },
data: { status: 'disconnected' },
});

View file

@ -1,6 +1,14 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
import { API_KEY_SCOPES } from '~/lib/api-keys';
import { authenticateRequest, getClientMetadata, hasScope } from '~/lib/api-keys/middleware';
import {
checkApiKeyRateLimit,
createRateLimitResponse,
getRateLimitHeaders,
} from '~/lib/api-keys/rate-limit';
import { trackUsage } from '~/lib/api-keys/usage';
import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/handlers';
export const runtime = 'nodejs';
@ -9,6 +17,9 @@ export const maxDuration = 60;
const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries
// Authentication is required for all MCP operations
const REQUIRE_AUTH = true;
interface RouteContext {
params: Promise<{ username: string; slug: string; transport: string }>;
}
@ -205,6 +216,9 @@ function handleSseGet(
* MCP JSON-RPC endpoint for tool execution
*/
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
const startTime = Date.now();
let authResult: Awaited<ReturnType<typeof authenticateRequest>> | null = null;
try {
const { username, slug, transport } = await context.params;
@ -219,6 +233,53 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
);
}
// Authenticate the request
authResult = await authenticateRequest();
// Check if auth is required
if (REQUIRE_AUTH && !authResult.authenticated) {
return NextResponse.json(
{
jsonrpc: '2.0',
error: { code: -32000, message: authResult.error || 'Authentication required' },
id: null,
},
{ status: 401 }
);
}
// Check scope if authenticated
if (authResult.authenticated && !hasScope(authResult, API_KEY_SCOPES.MCP_EXECUTE)) {
return NextResponse.json(
{
jsonrpc: '2.0',
error: { code: -32000, message: 'Missing required scope: mcp:execute' },
id: null,
},
{ status: 403 }
);
}
// Rate limit if authenticated via API key
if (authResult.authenticated && authResult.apiKeyId) {
const rateLimitResult = await checkApiKeyRateLimit(
authResult.apiKeyId,
authResult.tier || 'FREE'
);
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
}
// Log warning for unauthenticated requests (soft launch)
if (!authResult.authenticated && !REQUIRE_AUTH) {
console.warn(
`[MCP] Unauthenticated request to /${username}/${slug}/${transport} - ` +
'API key authentication will be required in a future update'
);
}
const collection = await getPublicCollectionByUsernameAndSlug(username, slug);
if (!collection) {
@ -228,14 +289,66 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
);
}
let response: Response;
if (transport === 'sse') {
return handleSseTransport(request, collection.id, collection.name);
response = await handleSseTransport(request, collection.id, collection.name);
} else {
response = await handleHttpTransport(request, collection.id, collection.name);
}
return handleHttpTransport(request, collection.id, collection.name);
// Track usage for authenticated requests
if (authResult.authenticated && authResult.userId) {
const clientMeta = await getClientMetadata();
trackUsage({
apiKeyId: authResult.apiKeyId,
userId: authResult.userId,
endpoint: `/api/mcp/${username}/${slug}/${transport}`,
method: 'POST',
statusCode: response.status,
latencyMs: Date.now() - startTime,
resourceType: 'mcp',
resourceId: collection.id,
userAgent: clientMeta.userAgent,
ipAddress: clientMeta.ipAddress,
});
}
// Add rate limit headers for authenticated requests
if (authResult.authenticated && authResult.apiKeyId) {
const rateLimitResult = await checkApiKeyRateLimit(
authResult.apiKeyId,
authResult.tier || 'FREE'
);
const headers = getRateLimitHeaders(rateLimitResult);
for (const [key, value] of Object.entries(headers)) {
response.headers.set(key, value);
}
}
return response;
} catch (error) {
console.error('[MCP POST] Error:', error);
const message = error instanceof Error ? error.message : 'Internal server error';
// Track error for authenticated requests
if (authResult?.authenticated && authResult.userId) {
const { username, slug, transport } = await context.params;
const clientMeta = await getClientMetadata();
trackUsage({
apiKeyId: authResult.apiKeyId,
userId: authResult.userId,
endpoint: `/api/mcp/${username}/${slug}/${transport}`,
method: 'POST',
statusCode: 500,
latencyMs: Date.now() - startTime,
resourceType: 'mcp',
errorCode: 'INTERNAL_ERROR',
errorMessage: message,
userAgent: clientMeta.userAgent,
ipAddress: clientMeta.ipAddress,
});
}
return NextResponse.json(
{ jsonrpc: '2.0', error: { code: -32603, message }, id: null },
{ status: 500 }

View file

@ -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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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>
);
}

View 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>
);
}

View file

@ -308,8 +308,8 @@ export default function APIDocsPage(): React.ReactElement {
<DocSection id="quick-start" title="Quick Start">
<p className="text-foreground-secondary mb-6">
Try these examples to get started immediately. All public endpoints work without
authentication.
Try these examples to get started. All API endpoints require authentication via API
key. Generate one from <strong>Settings TPMJS API Keys</strong> in your dashboard.
</p>
<div className="space-y-6">
@ -317,7 +317,8 @@ export default function APIDocsPage(): React.ReactElement {
<h3 className="text-lg font-semibold text-foreground mb-3">1. List Tools</h3>
<CodeBlock
language="bash"
code={`curl "https://tpmjs.com/api/tools?limit=5" | jq`}
code={`curl "https://tpmjs.com/api/tools?limit=5" \\
-H "Authorization: Bearer tpmjs_sk_your_api_key_here" | jq`}
/>
</div>
@ -325,7 +326,8 @@ export default function APIDocsPage(): React.ReactElement {
<h3 className="text-lg font-semibold text-foreground mb-3">2. Search Tools</h3>
<CodeBlock
language="bash"
code={`curl "https://tpmjs.com/api/tools/search?q=web+scraping&limit=3" | jq`}
code={`curl "https://tpmjs.com/api/tools/search?q=web+scraping&limit=3" \\
-H "Authorization: Bearer tpmjs_sk_your_api_key_here" | jq`}
/>
</div>
@ -335,7 +337,8 @@ export default function APIDocsPage(): React.ReactElement {
</h3>
<CodeBlock
language="bash"
code={`curl "https://tpmjs.com/api/tools/@tpmjs/hello/helloWorldTool" | jq`}
code={`curl "https://tpmjs.com/api/tools/@tpmjs/hello/helloWorldTool" \\
-H "Authorization: Bearer tpmjs_sk_your_api_key_here" | jq`}
/>
</div>
@ -346,6 +349,7 @@ export default function APIDocsPage(): React.ReactElement {
<CodeBlock
language="bash"
code={`curl -X POST "https://tpmjs.com/api/mcp/ajax/ajax-collection-tbc/http" \\
-H "Authorization: Bearer tpmjs_sk_your_api_key_here" \\
-H "Content-Type: application/json" \\
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq`}
/>
@ -355,29 +359,51 @@ export default function APIDocsPage(): React.ReactElement {
<DocSection id="authentication" title="Authentication">
<p className="text-foreground-secondary mb-6">
Most public endpoints don&apos;t require authentication. Private endpoints (creating
collections, managing agents) require a session cookie from signing in.
All API endpoints require authentication via TPMJS API keys. Generate an API key
from your dashboard at <strong>Settings TPMJS API Keys</strong>.
</p>
<div className="space-y-4">
<div className="p-4 border border-green-500/30 rounded-lg bg-green-500/5">
<h3 className="font-semibold text-foreground mb-2">Public (No Auth)</h3>
<ul className="text-sm text-foreground-secondary list-disc list-inside space-y-1">
<li>GET /api/tools - List and search tools</li>
<li>GET /api/public/collections - List public collections</li>
<li>GET /api/public/agents - List public agents</li>
<li>POST /api/mcp/[user]/[slug]/http - MCP protocol for public collections</li>
<li>GET /api/stats - Platform statistics</li>
</ul>
<div className="space-y-4 mb-6">
<div className="p-4 border border-primary/30 rounded-lg bg-primary/5">
<h3 className="font-semibold text-foreground mb-2">API Key Format</h3>
<p className="text-sm text-foreground-secondary mb-2">
API keys use the <code className="text-primary">tpmjs_sk_</code> prefix and are
passed in the Authorization header:
</p>
<CodeBlock
language="bash"
code={`curl "https://tpmjs.com/api/tools" \\
-H "Authorization: Bearer tpmjs_sk_your_api_key_here"`}
/>
</div>
<div className="p-4 border border-yellow-500/30 rounded-lg bg-yellow-500/5">
<h3 className="font-semibold text-foreground mb-2">Authenticated</h3>
<h3 className="font-semibold text-foreground mb-2">API Key Scopes</h3>
<ul className="text-sm text-foreground-secondary list-disc list-inside space-y-1">
<li>POST /api/collections - Create collection</li>
<li>POST /api/agents - Create agent</li>
<li>PUT /api/collections/[id] - Update collection</li>
<li>DELETE /api/agents/[id] - Delete agent</li>
<li>
<code className="text-primary">mcp:execute</code> - MCP tool execution
</li>
<li>
<code className="text-primary">agent:chat</code> - Agent conversations
</li>
<li>
<code className="text-primary">bridge:connect</code> - Bridge connections
</li>
<li>
<code className="text-primary">collection:read</code> - Collection access
</li>
<li>
<code className="text-primary">usage:read</code> - Usage analytics
</li>
</ul>
</div>
<div className="p-4 border border-green-500/30 rounded-lg bg-green-500/5">
<h3 className="font-semibold text-foreground mb-2">Rate Limits</h3>
<ul className="text-sm text-foreground-secondary list-disc list-inside space-y-1">
<li>FREE tier: 100 requests/hour</li>
<li>PRO tier: 1,000 requests/hour</li>
<li>ENTERPRISE tier: 10,000 requests/hour</li>
</ul>
</div>
</div>
@ -590,6 +616,9 @@ export default function APIDocsPage(): React.ReactElement {
<div className="p-4 border border-border rounded-lg bg-surface">
<h4 className="font-semibold text-foreground mb-2">Request Headers</h4>
<code className="text-sm text-foreground-secondary block">
Authorization: Bearer tpmjs_sk_your_api_key_here
</code>
<code className="text-sm text-foreground-secondary block mt-1">
Content-Type: application/json
</code>
</div>
@ -725,6 +754,7 @@ export default function APIDocsPage(): React.ReactElement {
<CodeBlock
language="bash"
code={`curl -X POST "https://tpmjs.com/api/mcp/ajax/ajax-collection-tbc/http" \\
-H "Authorization: Bearer tpmjs_sk_your_api_key_here" \\
-H "Content-Type: application/json" \\
-d '{
"jsonrpc": "2.0",
@ -813,7 +843,10 @@ export default function APIDocsPage(): React.ReactElement {
'https://tpmjs.com/api/tools/execute/@tpmjs/hello/helloWorldTool',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Authorization': 'Bearer tpmjs_sk_your_api_key_here',
'Content-Type': 'application/json',
},
body: JSON.stringify({ prompt: 'Say hello' }),
}
);

View file

@ -19,7 +19,9 @@ const navItems: NavItem[] = [
{ href: '/dashboard', label: 'Overview', icon: 'home' },
{ href: '/dashboard/agents', label: 'Agents', icon: 'terminal' },
{ href: '/dashboard/collections', label: 'Collections', icon: 'folder' },
{ href: '/dashboard/settings/api-keys', label: 'API Keys', icon: 'key' },
{ href: '/dashboard/usage', label: 'Usage', icon: 'globe' },
{ href: '/dashboard/settings/tpmjs-api-keys', label: 'TPMJS API Keys', icon: 'key' },
{ href: '/dashboard/settings/api-keys', label: 'Provider Keys', icon: 'edit' },
{ href: '/dashboard/settings/bridge', label: 'Bridge', icon: 'link' },
];

View 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];
}

View 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,
};
}

View 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),
},
}
);
}

View 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;
}

View file

@ -401,9 +401,9 @@ class TPMJSBridge {
})));
}
// 3. Connect to TPMJS WebSocket
// 3. Connect to TPMJS WebSocket (requires API key with bridge:connect scope)
this.ws = new WebSocket(
`${this.config.tpmjsUrl}/api/bridge?token=${this.config.apiKey}`
`${this.config.tpmjsUrl}/api/bridge?token=${this.config.apiKey}` // apiKey format: tpmjs_sk_...
);
this.ws.on('open', () => {
@ -443,6 +443,7 @@ class TPMJSBridge {
}
// CLI entry point
// API key is loaded from ~/.tpmjs/credentials.json (format: tpmjs_sk_...)
const config = loadConfig(); // from ~/.tpmjs/bridge.json
const bridge = new TPMJSBridge(config);
bridge.start();
@ -452,9 +453,12 @@ bridge.start();
Server-side handler for bridge connections.
**Authentication:** Requires TPMJS API key (format: `tpmjs_sk_...`) with `bridge:connect` scope.
```typescript
// apps/web/src/app/api/bridge/route.ts
import { prisma } from '@tpmjs/db';
import { authenticateRequest, hasScope } from '~/lib/api-keys/middleware';
export const runtime = 'nodejs';
@ -463,9 +467,9 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const token = searchParams.get('token');
// Validate API key
const user = await validateApiKey(token);
if (!user) {
// Validate API key (must have bridge:connect scope)
const authResult = await authenticateRequest();
if (!authResult.authenticated || !hasScope(authResult, 'bridge:connect')) {
return new Response('Unauthorized', { status: 401 });
}
@ -715,11 +719,19 @@ interface ToolExecutionError {
### Security Considerations
1. **API Key Authentication**: Bridge connections require valid API key
2. **User Isolation**: Each user's bridge is isolated
3. **Tool Whitelisting**: Users explicitly add tools to collections
4. **Encrypted Credentials**: Remote MCP server credentials encrypted at rest
5. **WebSocket Security**: WSS (TLS) required for bridge connections
1. **API Key Authentication**: All API endpoints require a valid TPMJS API key (`tpmjs_sk_...` prefix)
2. **Scope-Based Access**: API keys have specific scopes (e.g., `bridge:connect`, `mcp:execute`, `agent:chat`)
3. **User Isolation**: Each user's bridge is isolated
4. **Tool Whitelisting**: Users explicitly add tools to collections
5. **Encrypted Credentials**: Remote MCP server credentials encrypted at rest
6. **WebSocket Security**: WSS (TLS) required for bridge connections
**Required API Key Scopes:**
- `bridge:connect` - For bridge WebSocket connections
- `mcp:execute` - For MCP tool execution
- `collection:read` - For accessing collection data
Generate API keys from Settings > TPMJS API Keys in the dashboard.
---
@ -819,12 +831,17 @@ After setup, user only needs ONE MCP server in their config:
"mcpServers": {
"tpmjs": {
"type": "url",
"url": "https://tpmjs.com/api/mcp/username/all-my-tools/http"
"url": "https://tpmjs.com/api/mcp/username/all-my-tools/http",
"headers": {
"Authorization": "Bearer tpmjs_sk_your_api_key_here"
}
}
}
}
```
**Note:** Generate your API key from Settings > TPMJS API Keys. The key requires `mcp:execute` scope.
This single endpoint provides access to:
- All npm tools in the collection
- All remote MCP tools configured

View file

@ -648,7 +648,7 @@ npx @tpmjs/bridge <command>
**~/.tpmjs/credentials.json**
```json
{
"apiKey": "tpmjs_xxxxxxxxxxxxxxxxxxxx",
"apiKey": "tpmjs_sk_xxxxxxxxxxxxxxxxxxxx",
"userId": "user_abc123",
"email": "user@example.com",
"expiresAt": "2026-01-12T00:00:00Z"
@ -703,9 +703,11 @@ await manager.disconnect('chrome');
#### Connection
```
wss://tpmjs.com/api/bridge?token=tpmjs_xxxx
wss://tpmjs.com/api/bridge?token=tpmjs_sk_your_api_key_here
```
**Note:** All TPMJS API endpoints require authentication. Generate an API key from your dashboard at Settings > TPMJS API Keys. API keys use the `tpmjs_sk_` prefix.
#### Messages: Bridge → TPMJS
**Register Tools**
@ -907,16 +909,28 @@ ALTER TABLE "CollectionBridgeTool" ADD CONSTRAINT "CollectionBridgeTool_collecti
**Endpoint**: `GET /api/bridge`
**Query Parameters**:
- `token` (required): User's API key
- `token` (required): User's TPMJS API key (format: `tpmjs_sk_...`)
**Upgrade**: WebSocket
**Authentication**: Validates API key, returns 401 if invalid
**Authentication**: Validates API key with `bridge:connect` scope, returns 401 if invalid
**Example**:
```bash
# Connect via WebSocket with API key
wscat -c 'wss://tpmjs.com/api/bridge?token=tpmjs_sk_your_api_key_here'
```
### Bridge Status API
**Endpoint**: `GET /api/user/bridge`
**Authentication**: Requires API key with `bridge:connect` scope
```bash
curl https://tpmjs.com/api/user/bridge \
-H 'Authorization: Bearer tpmjs_sk_your_api_key_here'
```
**Response**:
```json
{
@ -945,13 +959,18 @@ ALTER TABLE "CollectionBridgeTool" ADD CONSTRAINT "CollectionBridgeTool_collecti
### Collection Bridge Tools API
All collection endpoints require API key with `collection:read` scope.
**Add Tool**: `POST /api/collections/{id}/bridge-tools`
```json
{
"serverId": "chrome-devtools",
"toolName": "screenshot"
}
```bash
curl -X POST 'https://tpmjs.com/api/collections/{id}/bridge-tools' \
-H 'Authorization: Bearer tpmjs_sk_your_api_key_here' \
-H 'Content-Type: application/json' \
-d '{
"serverId": "chrome-devtools",
"toolName": "screenshot"
}'
```
**Remove Tool**: `DELETE /api/collections/{id}/bridge-tools/{toolId}`

View file

@ -159,8 +159,9 @@ This happened with the `startTime is not defined` bug. Our executor code had a b
### Investigating a Specific Tool
```bash
# Check current health status
curl -s 'https://tpmjs.com/api/tools?limit=50' | \
# Check current health status (requires API key)
curl -s 'https://tpmjs.com/api/tools?limit=50' \
-H 'Authorization: Bearer tpmjs_sk_your_api_key_here' | \
jq '.data[] | select(.package.npmPackageName == "PACKAGE_NAME") | {
packageName: .package.npmPackageName,
exportName: .exportName,
@ -179,10 +180,11 @@ cat package/dist/index.js
### Manually Updating Health Status
For testing or correction:
For testing or correction (requires API key with appropriate scope):
```bash
curl -X POST 'https://tpmjs.com/api/tools/report-health' \
-H 'Authorization: Bearer tpmjs_sk_your_api_key_here' \
-H 'Content-Type: application/json' \
-d '{
"packageName": "@scope/package",

View file

@ -334,6 +334,9 @@ model User {
image String?
username String? @unique @db.VarChar(30) // URL-friendly username (nullable for migration)
// Tier for rate limiting and feature access
tier UserTier @default(FREE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -347,6 +350,8 @@ model User {
agentLikes AgentLike[]
activities UserActivity[]
bridgeConnection BridgeConnection?
tpmjsApiKeys TpmjsApiKey[] @relation("UserTpmjsApiKeys")
usageSummaries ApiUsageSummary[] @relation("UserUsageSummaries")
@@index([username])
@@map("users")
@ -886,3 +891,137 @@ model CollectionBridgeTool {
@@index([collectionId])
@@map("collection_bridge_tools")
}
// ============================================================================
// API Key & Usage Tracking Models
// ============================================================================
/// User tier enum - determines rate limits and feature access
enum UserTier {
FREE
PRO
ENTERPRISE
}
/// TpmjsApiKey - user-owned API keys for programmatic access
model TpmjsApiKey {
id String @id @default(cuid())
// Owner relationship
userId String @map("user_id")
user User @relation("UserTpmjsApiKeys", fields: [userId], references: [id], onDelete: Cascade)
// Key identification
name String @db.VarChar(100) // User-provided name (e.g., "Production Server")
keyHash String @unique @map("key_hash") @db.VarChar(64) // SHA-256 hash (never store raw keys)
keyPrefix String @map("key_prefix") @db.VarChar(20) // First 16 chars for identification (tpmjs_sk_abc123...)
// Permissions
scopes String[] @default([]) // ["mcp:execute", "agent:chat", "bridge:connect", "usage:read"]
// Rate limiting (overrides tier default if set)
rateLimit Int? @map("rate_limit") // Requests per hour (null = use tier default)
// Status
isActive Boolean @default(true) @map("is_active")
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
usageRecords ApiUsageRecord[]
@@index([userId])
@@index([keyHash])
@@index([keyPrefix])
@@index([isActive])
@@map("tpmjs_api_keys")
}
/// ApiUsageRecord - individual API request logs (kept for 30 days)
model ApiUsageRecord {
id String @id @default(cuid())
// API key relationship
apiKeyId String @map("api_key_id")
apiKey TpmjsApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
// Request details
endpoint String @db.VarChar(500)
method String @db.VarChar(10) // GET, POST, etc.
statusCode Int @map("status_code")
latencyMs Int @map("latency_ms")
// Resource tracking
resourceType String? @map("resource_type") @db.VarChar(50) // "mcp" | "agent" | "bridge" | "collection"
resourceId String? @map("resource_id") @db.VarChar(100) // Collection ID, Agent ID, etc.
// LLM usage (if applicable)
tokensIn Int? @map("tokens_in")
tokensOut Int? @map("tokens_out")
model String? @db.VarChar(50) // e.g., "gpt-4o-mini"
// Error tracking
errorCode String? @map("error_code") @db.VarChar(50)
errorMessage String? @map("error_message") @db.Text
// Client metadata
userAgent String? @map("user_agent") @db.VarChar(500)
ipAddress String? @map("ip_address") @db.VarChar(45) // IPv4 or IPv6
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
@@index([apiKeyId])
@@index([createdAt])
@@index([endpoint])
@@index([resourceType, resourceId])
@@map("api_usage_records")
}
/// ApiUsageSummary - aggregated usage summaries (hourly/daily rollups)
model ApiUsageSummary {
id String @id @default(cuid())
// User relationship
userId String @map("user_id")
user User @relation("UserUsageSummaries", fields: [userId], references: [id], onDelete: Cascade)
// Optional API key (null for user-level summaries)
apiKeyId String? @map("api_key_id")
// Time period
periodType String @map("period_type") @db.VarChar(20) // "hourly" | "daily" | "monthly"
periodStart DateTime @map("period_start")
// Request counts
totalRequests Int @default(0) @map("total_requests")
successRequests Int @default(0) @map("success_requests")
errorRequests Int @default(0) @map("error_requests")
// Endpoint breakdown (JSON: { "/api/mcp/...": 100, ... })
endpointCounts Json @default("{}") @map("endpoint_counts") @db.JsonB
// LLM usage totals
totalTokensIn Int @default(0) @map("total_tokens_in")
totalTokensOut Int @default(0) @map("total_tokens_out")
// Performance
avgLatencyMs Float @default(0) @map("avg_latency_ms")
p95LatencyMs Float @default(0) @map("p95_latency_ms")
// Cost estimation (in cents)
estimatedCostCents Int @default(0) @map("estimated_cost_cents")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, apiKeyId, periodType, periodStart])
@@index([userId])
@@index([periodType, periodStart])
@@map("api_usage_summaries")
}