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:
Vectry
2026-02-10 15:37:49 +00:00
parent 07cf717c15
commit 61268f870f
33 changed files with 2247 additions and 57 deletions

View File

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