feat: user auth, API keys, Stripe billing, and dashboard scoping
- NextAuth v5 credentials auth with registration/login pages - API key CRUD (create, list, revoke) with secure hashing - Stripe checkout, webhooks, and customer portal integration - Rate limiting per subscription tier - All dashboard API endpoints scoped to authenticated user - Prisma schema: User, Account, Session, ApiKey, plus Stripe fields - Auth middleware protecting dashboard and API routes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@agentlens/database";
|
||||
import { validateApiKey } from "@/lib/api-key";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
// Types
|
||||
interface DecisionPointPayload {
|
||||
@@ -90,11 +92,55 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
if (!apiKey) {
|
||||
const rawApiKey = authHeader.slice(7);
|
||||
if (!rawApiKey) {
|
||||
return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 });
|
||||
}
|
||||
|
||||
const keyValidation = await validateApiKey(rawApiKey);
|
||||
if (!keyValidation) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId, subscription } = keyValidation;
|
||||
|
||||
if (!subscription) {
|
||||
return NextResponse.json({ error: "No subscription found for this user" }, { status: 403 });
|
||||
}
|
||||
|
||||
const tier = subscription.tier;
|
||||
const sessionsLimit = subscription.sessionsLimit;
|
||||
|
||||
if (tier === "FREE") {
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const dailyCount = await prisma.trace.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: startOfToday },
|
||||
},
|
||||
});
|
||||
|
||||
if (dailyCount >= sessionsLimit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Rate limit exceeded. Current plan: ${tier}. Limit: ${sessionsLimit}/day. Upgrade at /settings/billing`,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (subscription.sessionsUsed >= sessionsLimit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Rate limit exceeded. Current plan: ${tier}. Limit: ${sessionsLimit}/month. Upgrade at /settings/billing`,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const body: BatchTracesRequest = await request.json();
|
||||
if (!body.traces || !Array.isArray(body.traces)) {
|
||||
@@ -190,8 +236,14 @@ export async function POST(request: NextRequest) {
|
||||
// final flushes both work seamlessly.
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const upserted: string[] = [];
|
||||
let newTraceCount = 0;
|
||||
|
||||
for (const trace of body.traces) {
|
||||
const existing = await tx.trace.findUnique({
|
||||
where: { id: trace.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const traceData = {
|
||||
name: trace.name,
|
||||
sessionId: trace.sessionId,
|
||||
@@ -205,13 +257,16 @@ export async function POST(request: NextRequest) {
|
||||
endedAt: trace.endedAt ? new Date(trace.endedAt) : null,
|
||||
};
|
||||
|
||||
// 1. Upsert the trace record
|
||||
await tx.trace.upsert({
|
||||
where: { id: trace.id },
|
||||
create: { id: trace.id, ...traceData },
|
||||
create: { id: trace.id, userId, ...traceData },
|
||||
update: traceData,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
newTraceCount++;
|
||||
}
|
||||
|
||||
// 2. Delete existing child records (order matters for FK constraints:
|
||||
// decision points reference spans, so delete decisions first)
|
||||
await tx.decisionPoint.deleteMany({ where: { traceId: trace.id } });
|
||||
@@ -283,6 +338,13 @@ export async function POST(request: NextRequest) {
|
||||
upserted.push(trace.id);
|
||||
}
|
||||
|
||||
if (newTraceCount > 0 && tier !== "FREE") {
|
||||
await tx.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { sessionsUsed: { increment: newTraceCount } },
|
||||
});
|
||||
}
|
||||
|
||||
return upserted;
|
||||
});
|
||||
|
||||
@@ -300,6 +362,11 @@ export async function POST(request: NextRequest) {
|
||||
// GET /api/traces — List traces with pagination
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
||||
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
|
||||
@@ -339,8 +406,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Invalid dateTo parameter. Must be a valid ISO date string." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {};
|
||||
const where: Record<string, unknown> = { userId: session.user.id };
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user