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

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { hash } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
const registerSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
name: z.string().min(1).optional(),
});
export async function POST(request: Request) {
try {
const body: unknown = await request.json();
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const { email, password, name } = parsed.data;
const normalizedEmail = email.toLowerCase();
const existing = await prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (existing) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
);
}
const passwordHash = await hash(password, 12);
const user = await prisma.user.create({
data: {
email: normalizedEmail,
passwordHash,
name: name ?? null,
subscription: {
create: {
tier: "FREE",
sessionsLimit: 20,
},
},
},
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
return NextResponse.json(user, { status: 201 });
} catch {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -1,9 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@agentlens/database";
import { auth } from "@/auth";
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);
@@ -51,8 +57,9 @@ export async function GET(request: NextRequest) {
);
}
// Build where clause
const where: Prisma.DecisionPointWhereInput = {};
const where: Prisma.DecisionPointWhereInput = {
trace: { userId: session.user.id },
};
if (type) {
where.type = type as Prisma.EnumDecisionTypeFilter["equals"];
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const apiKey = await prisma.apiKey.findFirst({
where: { id, userId: session.user.id, revoked: false },
select: { id: true },
});
if (!apiKey) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await prisma.apiKey.update({
where: { id: apiKey.id },
data: { revoked: true },
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Error revoking API key:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { randomBytes, createHash } from "crypto";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const keys = await prisma.apiKey.findMany({
where: { userId: session.user.id, revoked: false },
select: {
id: true,
name: true,
keyPrefix: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: "desc" },
});
return NextResponse.json(keys, { status: 200 });
} catch (error) {
console.error("Error listing API keys:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json().catch(() => ({}));
const name =
typeof body.name === "string" && body.name.trim()
? body.name.trim()
: "Default";
const rawHex = randomBytes(24).toString("hex");
const fullKey = `al_${rawHex}`;
const keyPrefix = fullKey.slice(0, 10);
const keyHash = createHash("sha256").update(fullKey).digest("hex");
const apiKey = await prisma.apiKey.create({
data: {
userId: session.user.id,
name,
keyHash,
keyPrefix,
},
select: {
id: true,
name: true,
keyPrefix: true,
createdAt: true,
},
});
return NextResponse.json(
{ ...apiKey, key: fullKey },
{ status: 201 }
);
} catch (error) {
console.error("Error creating API key:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
email: true,
name: true,
createdAt: true,
subscription: {
select: {
tier: true,
status: true,
sessionsUsed: true,
sessionsLimit: true,
currentPeriodStart: true,
currentPeriodEnd: true,
stripeCustomerId: true,
},
},
},
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Don't expose raw Stripe customer ID to the client
const { subscription, ...rest } = user;
const safeSubscription = subscription
? {
tier: subscription.tier,
status: subscription.status,
sessionsUsed: subscription.sessionsUsed,
sessionsLimit: subscription.sessionsLimit,
currentPeriodStart: subscription.currentPeriodStart,
currentPeriodEnd: subscription.currentPeriodEnd,
hasStripeSubscription: !!subscription.stripeCustomerId,
}
: null;
return NextResponse.json({ ...rest, subscription: safeSubscription }, { status: 200 });
} catch (error) {
console.error("Error fetching account:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -1,13 +1,22 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export async function POST() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = session.user.id;
const traceFilter = { trace: { userId } };
await prisma.$transaction([
prisma.event.deleteMany(),
prisma.decisionPoint.deleteMany(),
prisma.span.deleteMany(),
prisma.trace.deleteMany(),
prisma.event.deleteMany({ where: traceFilter }),
prisma.decisionPoint.deleteMany({ where: traceFilter }),
prisma.span.deleteMany({ where: traceFilter }),
prisma.trace.deleteMany({ where: { userId } }),
]);
return NextResponse.json({ success: true }, { status: 200 });

View File

@@ -1,14 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = session.user.id;
const traceFilter = { userId };
const childFilter = { trace: { userId } };
const [totalTraces, totalSpans, totalDecisions, totalEvents] =
await Promise.all([
prisma.trace.count(),
prisma.span.count(),
prisma.decisionPoint.count(),
prisma.event.count(),
prisma.trace.count({ where: traceFilter }),
prisma.span.count({ where: childFilter }),
prisma.decisionPoint.count({ where: childFilter }),
prisma.event.count({ where: childFilter }),
]);
return NextResponse.json(

View File

@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { priceId, tierKey } = body as {
priceId?: string;
tierKey?: string;
};
let resolvedPriceId = priceId;
if (!resolvedPriceId && tierKey) {
const tierConfig =
TIER_CONFIG[tierKey as keyof typeof TIER_CONFIG];
if (tierConfig && "priceId" in tierConfig) {
resolvedPriceId = tierConfig.priceId;
}
}
if (!resolvedPriceId) {
return NextResponse.json(
{ error: "priceId or tierKey is required" },
{ status: 400 }
);
}
const validPriceIds = [TIER_CONFIG.STARTER.priceId, TIER_CONFIG.PRO.priceId];
if (!validPriceIds.includes(resolvedPriceId)) {
return NextResponse.json(
{ error: "Invalid priceId" },
{ status: 400 }
);
}
const userId = session.user.id;
let subscription = await prisma.subscription.findUnique({
where: { userId },
});
let stripeCustomerId = subscription?.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await getStripe().customers.create({
email: session.user.email,
name: session.user.name ?? undefined,
metadata: { userId },
});
stripeCustomerId = customer.id;
if (subscription) {
await prisma.subscription.update({
where: { userId },
data: { stripeCustomerId },
});
} else {
subscription = await prisma.subscription.create({
data: {
userId,
stripeCustomerId,
},
});
}
}
const origin =
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
const checkoutSession = await getStripe().checkout.sessions.create({
customer: stripeCustomerId,
mode: "subscription",
line_items: [{ price: resolvedPriceId, quantity: 1 }],
success_url: `${origin}/dashboard/settings?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/dashboard/settings`,
metadata: { userId },
});
return NextResponse.json({ url: checkoutSession.url }, { status: 200 });
} catch (error) {
console.error("Error creating checkout session:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { getStripe } from "@/lib/stripe";
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const subscription = await prisma.subscription.findUnique({
where: { userId: session.user.id },
select: { stripeCustomerId: true },
});
if (!subscription?.stripeCustomerId) {
return NextResponse.json(
{ error: "No active subscription to manage" },
{ status: 400 }
);
}
const origin =
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
const portalSession = await getStripe().billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${origin}/dashboard/settings`,
});
return NextResponse.json({ url: portalSession.url }, { status: 200 });
} catch (error) {
console.error("Error creating portal session:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,179 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
export const runtime = "nodejs";
function tierFromPriceId(priceId: string | null): "FREE" | "STARTER" | "PRO" {
if (priceId === TIER_CONFIG.STARTER.priceId) return "STARTER";
if (priceId === TIER_CONFIG.PRO.priceId) return "PRO";
return "FREE";
}
function sessionsLimitForTier(tier: "FREE" | "STARTER" | "PRO"): number {
return TIER_CONFIG[tier].sessionsLimit;
}
async function handleCheckoutCompleted(
checkoutSession: Stripe.Checkout.Session
) {
const userId = checkoutSession.metadata?.userId;
if (!userId) return;
const subscriptionId = checkoutSession.subscription as string;
const customerId = checkoutSession.customer as string;
const sub = await getStripe().subscriptions.retrieve(subscriptionId);
const firstItem = sub.items.data[0];
const priceId = firstItem?.price?.id ?? null;
const tier = tierFromPriceId(priceId);
const periodStart = firstItem?.current_period_start
? new Date(firstItem.current_period_start * 1000)
: new Date();
const periodEnd = firstItem?.current_period_end
? new Date(firstItem.current_period_end * 1000)
: new Date();
await prisma.subscription.upsert({
where: { userId },
update: {
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
stripePriceId: priceId,
tier,
sessionsLimit: sessionsLimitForTier(tier),
sessionsUsed: 0,
status: "ACTIVE",
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
},
create: {
userId,
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
stripePriceId: priceId,
tier,
sessionsLimit: sessionsLimitForTier(tier),
sessionsUsed: 0,
status: "ACTIVE",
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
},
});
}
async function handleSubscriptionUpdated(sub: Stripe.Subscription) {
const firstItem = sub.items.data[0];
const priceId = firstItem?.price?.id ?? null;
const tier = tierFromPriceId(priceId);
const statusMap: Record<string, "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID"> = {
active: "ACTIVE",
past_due: "PAST_DUE",
canceled: "CANCELED",
unpaid: "UNPAID",
};
const dbStatus = statusMap[sub.status] ?? "ACTIVE";
const periodStart = firstItem?.current_period_start
? new Date(firstItem.current_period_start * 1000)
: undefined;
const periodEnd = firstItem?.current_period_end
? new Date(firstItem.current_period_end * 1000)
: undefined;
await prisma.subscription.updateMany({
where: { stripeSubscriptionId: sub.id },
data: {
tier,
stripePriceId: priceId,
sessionsLimit: sessionsLimitForTier(tier),
status: dbStatus,
...(periodStart && { currentPeriodStart: periodStart }),
...(periodEnd && { currentPeriodEnd: periodEnd }),
},
});
}
async function handleSubscriptionDeleted(sub: Stripe.Subscription) {
await prisma.subscription.updateMany({
where: { stripeSubscriptionId: sub.id },
data: {
status: "CANCELED",
tier: "FREE",
sessionsLimit: TIER_CONFIG.FREE.sessionsLimit,
},
});
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
const subDetail = invoice.parent?.subscription_details?.subscription;
const subscriptionId =
typeof subDetail === "string" ? subDetail : subDetail?.id;
if (!subscriptionId) return;
await prisma.subscription.updateMany({
where: { stripeSubscriptionId: subscriptionId },
data: { sessionsUsed: 0 },
});
}
export async function POST(request: Request) {
const body = await request.text();
const sig = request.headers.get("stripe-signature");
if (!sig) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = getStripe().webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed");
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
break;
case "invoice.paid":
await handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
}
} catch (error) {
console.error(`Error handling ${event.type}:`, error);
return NextResponse.json(
{ error: "Webhook handler failed" },
{ status: 500 }
);
}
return NextResponse.json({ received: true }, { status: 200 });
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
type RouteParams = { params: Promise<{ id: string }> };
@@ -23,14 +24,19 @@ export async function GET(
{ params }: RouteParams
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
if (!id || typeof id !== "string") {
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
}
const trace = await prisma.trace.findUnique({
where: { id },
const trace = await prisma.trace.findFirst({
where: { id, userId: session.user.id },
include: {
decisionPoints: {
orderBy: {
@@ -106,14 +112,19 @@ export async function DELETE(
{ params }: RouteParams
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
if (!id || typeof id !== "string") {
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
}
const trace = await prisma.trace.findUnique({
where: { id },
const trace = await prisma.trace.findFirst({
where: { id, userId: session.user.id },
select: { id: true },
});

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

View File

@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export const dynamic = "force-dynamic";
@@ -22,6 +23,13 @@ interface TraceUpdateData {
}
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const currentUserId = session.user.id;
const headers = new Headers();
headers.set("Content-Type", "text/event-stream");
headers.set("Cache-Control", "no-cache");
@@ -43,6 +51,7 @@ export async function GET(request: NextRequest) {
try {
const newTraces = await prisma.trace.findMany({
where: {
userId: currentUserId,
OR: [
{ createdAt: { gt: lastCheck } },
{ updatedAt: { gt: lastCheck } },