From 61268f870fb734a541a98b10b41461d734ade32c Mon Sep 17 00:00:00 2001 From: Vectry Date: Tue, 10 Feb 2026 15:37:49 +0000 Subject: [PATCH] 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 --- apps/web/package.json | 7 +- apps/web/src/app/(auth)/layout.tsx | 11 + apps/web/src/app/(auth)/login/page.tsx | 158 ++++++++ apps/web/src/app/(auth)/register/page.tsx | 202 ++++++++++ .../src/app/api/auth/[...nextauth]/route.ts | 3 + apps/web/src/app/api/auth/register/route.ts | 67 ++++ apps/web/src/app/api/decisions/route.ts | 11 +- apps/web/src/app/api/keys/[id]/route.ts | 38 ++ apps/web/src/app/api/keys/route.ts | 77 ++++ .../web/src/app/api/settings/account/route.ts | 59 +++ apps/web/src/app/api/settings/purge/route.ts | 17 +- apps/web/src/app/api/settings/stats/route.ts | 18 +- apps/web/src/app/api/stripe/checkout/route.ts | 95 +++++ apps/web/src/app/api/stripe/portal/route.ts | 41 ++ apps/web/src/app/api/stripe/webhook/route.ts | 179 +++++++++ apps/web/src/app/api/traces/[id]/route.ts | 19 +- apps/web/src/app/api/traces/route.ts | 78 +++- apps/web/src/app/api/traces/stream/route.ts | 11 +- apps/web/src/app/dashboard/keys/page.tsx | 340 ++++++++++++++++ apps/web/src/app/dashboard/layout.tsx | 2 + apps/web/src/app/dashboard/settings/page.tsx | 375 ++++++++++++++++-- .../web/src/app/docs/getting-started/page.tsx | 38 +- apps/web/src/app/docs/python-sdk/page.tsx | 4 +- apps/web/src/app/docs/typescript-sdk/page.tsx | 4 +- apps/web/src/app/layout.tsx | 3 +- apps/web/src/auth.config.ts | 9 + apps/web/src/auth.ts | 72 ++++ apps/web/src/lib/api-key.ts | 33 ++ apps/web/src/lib/stripe.ts | 35 ++ apps/web/src/middleware.ts | 50 +++ docker-compose.yml | 8 +- package-lock.json | 159 +++++++- packages/database/prisma/schema.prisma | 81 ++++ 33 files changed, 2247 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/app/(auth)/layout.tsx create mode 100644 apps/web/src/app/(auth)/login/page.tsx create mode 100644 apps/web/src/app/(auth)/register/page.tsx create mode 100644 apps/web/src/app/api/auth/[...nextauth]/route.ts create mode 100644 apps/web/src/app/api/auth/register/route.ts create mode 100644 apps/web/src/app/api/keys/[id]/route.ts create mode 100644 apps/web/src/app/api/keys/route.ts create mode 100644 apps/web/src/app/api/settings/account/route.ts create mode 100644 apps/web/src/app/api/stripe/checkout/route.ts create mode 100644 apps/web/src/app/api/stripe/portal/route.ts create mode 100644 apps/web/src/app/api/stripe/webhook/route.ts create mode 100644 apps/web/src/app/dashboard/keys/page.tsx create mode 100644 apps/web/src/auth.config.ts create mode 100644 apps/web/src/auth.ts create mode 100644 apps/web/src/lib/api-key.ts create mode 100644 apps/web/src/lib/stripe.ts create mode 100644 apps/web/src/middleware.ts diff --git a/apps/web/package.json b/apps/web/package.json index f74a265..efe4a5b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,14 +15,19 @@ "@agentlens/database": "*", "@dagrejs/dagre": "^2.0.4", "@xyflow/react": "^12.10.0", + "bcryptjs": "^3.0.3", "lucide-react": "^0.469.0", "next": "^15.1.0", + "next-auth": "^5.0.0-beta.30", "react": "^19.0.0", "react-dom": "^19.0.0", - "shiki": "^3.22.0" + "shiki": "^3.22.0", + "stripe": "^20.3.1", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.0", + "@types/bcryptjs": "^2.4.6", "@types/dagre": "^0.7.53", "@types/node": "^22.0.0", "@types/react": "^19.0.0", diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..5abb6c8 --- /dev/null +++ b/apps/web/src/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..6fae869 --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Activity, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + const passwordValid = password.length >= 8; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (!emailValid) { + setError("Please enter a valid email address"); + return; + } + if (!passwordValid) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + + if (result?.error) { + setError("Invalid email or password"); + setLoading(false); + return; + } + + router.push("/dashboard"); + router.refresh(); + } + + return ( +
+
+
+ +
+
+

Welcome back

+

+ Sign in to your AgentLens account +

+
+
+ +
+
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className={cn( + "w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", + email && !emailValid + ? "border-red-500/50 focus:border-red-500" + : "border-neutral-800 focus:border-emerald-500" + )} + /> + {email && !emailValid && ( +

+ Please enter a valid email address +

+ )} +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className={cn( + "w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", + password && !passwordValid + ? "border-red-500/50 focus:border-red-500" + : "border-neutral-800 focus:border-emerald-500" + )} + /> + {password && !passwordValid && ( +

+ Password must be at least 8 characters +

+ )} +
+
+ + {error && ( +
+

{error}

+
+ )} + + +
+ +

+ Don't have an account?{" "} + + Create one + +

+
+ ); +} diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..85177c2 --- /dev/null +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Activity, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export default function RegisterPage() { + const router = useRouter(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + const passwordValid = password.length >= 8; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (!emailValid) { + setError("Please enter a valid email address"); + return; + } + if (!passwordValid) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password, + ...(name.trim() ? { name: name.trim() } : {}), + }), + }); + + if (!res.ok) { + const data: { error?: string } = await res.json(); + setError(data.error ?? "Registration failed"); + setLoading(false); + return; + } + + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + + if (result?.error) { + setError("Account created but sign-in failed. Please log in manually."); + setLoading(false); + return; + } + + router.push("/dashboard"); + router.refresh(); + } catch { + setError("Something went wrong. Please try again."); + setLoading(false); + } + } + + return ( +
+
+
+ +
+
+

+ Create your account +

+

+ Start monitoring your AI agents with AgentLens +

+
+
+ +
+
+
+ + setName(e.target.value)} + placeholder="Jane Doe" + className="w-full px-3 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors focus:border-emerald-500" + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className={cn( + "w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", + email && !emailValid + ? "border-red-500/50 focus:border-red-500" + : "border-neutral-800 focus:border-emerald-500" + )} + /> + {email && !emailValid && ( +

+ Please enter a valid email address +

+ )} +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className={cn( + "w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", + password && !passwordValid + ? "border-red-500/50 focus:border-red-500" + : "border-neutral-800 focus:border-emerald-500" + )} + /> + {password && !passwordValid && ( +

+ Password must be at least 8 characters +

+ )} +
+
+ + {error && ( +
+

{error}

+
+ )} + + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+ ); +} diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..86c9f3d --- /dev/null +++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/apps/web/src/app/api/auth/register/route.ts b/apps/web/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..152c42a --- /dev/null +++ b/apps/web/src/app/api/auth/register/route.ts @@ -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 } + ); + } +} diff --git a/apps/web/src/app/api/decisions/route.ts b/apps/web/src/app/api/decisions/route.ts index dd77e88..c1bf127 100644 --- a/apps/web/src/app/api/decisions/route.ts +++ b/apps/web/src/app/api/decisions/route.ts @@ -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"]; } diff --git a/apps/web/src/app/api/keys/[id]/route.ts b/apps/web/src/app/api/keys/[id]/route.ts new file mode 100644 index 0000000..ad76ae0 --- /dev/null +++ b/apps/web/src/app/api/keys/[id]/route.ts @@ -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 } + ); + } +} diff --git a/apps/web/src/app/api/keys/route.ts b/apps/web/src/app/api/keys/route.ts new file mode 100644 index 0000000..3bb3bb7 --- /dev/null +++ b/apps/web/src/app/api/keys/route.ts @@ -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 } + ); + } +} diff --git a/apps/web/src/app/api/settings/account/route.ts b/apps/web/src/app/api/settings/account/route.ts new file mode 100644 index 0000000..bad1973 --- /dev/null +++ b/apps/web/src/app/api/settings/account/route.ts @@ -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 } + ); + } +} diff --git a/apps/web/src/app/api/settings/purge/route.ts b/apps/web/src/app/api/settings/purge/route.ts index 819eb6f..7888283 100644 --- a/apps/web/src/app/api/settings/purge/route.ts +++ b/apps/web/src/app/api/settings/purge/route.ts @@ -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 }); diff --git a/apps/web/src/app/api/settings/stats/route.ts b/apps/web/src/app/api/settings/stats/route.ts index 85fc13e..ebbfad0 100644 --- a/apps/web/src/app/api/settings/stats/route.ts +++ b/apps/web/src/app/api/settings/stats/route.ts @@ -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( diff --git a/apps/web/src/app/api/stripe/checkout/route.ts b/apps/web/src/app/api/stripe/checkout/route.ts new file mode 100644 index 0000000..4182db8 --- /dev/null +++ b/apps/web/src/app/api/stripe/checkout/route.ts @@ -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 } + ); + } +} diff --git a/apps/web/src/app/api/stripe/portal/route.ts b/apps/web/src/app/api/stripe/portal/route.ts new file mode 100644 index 0000000..5bd2ff9 --- /dev/null +++ b/apps/web/src/app/api/stripe/portal/route.ts @@ -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 } + ); + } +} diff --git a/apps/web/src/app/api/stripe/webhook/route.ts b/apps/web/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..095129a --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/route.ts @@ -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 = { + 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 }); +} diff --git a/apps/web/src/app/api/traces/[id]/route.ts b/apps/web/src/app/api/traces/[id]/route.ts index 6e79afb..c58598c 100644 --- a/apps/web/src/app/api/traces/[id]/route.ts +++ b/apps/web/src/app/api/traces/[id]/route.ts @@ -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 }, }); diff --git a/apps/web/src/app/api/traces/route.ts b/apps/web/src/app/api/traces/route.ts index da20b9b..d582a7f 100644 --- a/apps/web/src/app/api/traces/route.ts +++ b/apps/web/src/app/api/traces/route.ts @@ -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 = {}; + const where: Record = { userId: session.user.id }; if (status) { where.status = status; } diff --git a/apps/web/src/app/api/traces/stream/route.ts b/apps/web/src/app/api/traces/stream/route.ts index cd12ec8..66c74f7 100644 --- a/apps/web/src/app/api/traces/stream/route.ts +++ b/apps/web/src/app/api/traces/stream/route.ts @@ -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 } }, diff --git a/apps/web/src/app/dashboard/keys/page.tsx b/apps/web/src/app/dashboard/keys/page.tsx new file mode 100644 index 0000000..2cdf260 --- /dev/null +++ b/apps/web/src/app/dashboard/keys/page.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Key, + Plus, + Copy, + Check, + Trash2, + AlertTriangle, + RefreshCw, + Shield, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ApiKey { + id: string; + name: string; + keyPrefix: string; + createdAt: string; + lastUsedAt: string | null; +} + +interface NewKeyResponse extends ApiKey { + key: string; +} + +export default function ApiKeysPage() { + const [keys, setKeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [showCreateForm, setShowCreateForm] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [newlyCreatedKey, setNewlyCreatedKey] = useState( + null + ); + const [copiedField, setCopiedField] = useState(null); + const [revokingId, setRevokingId] = useState(null); + const [confirmRevokeId, setConfirmRevokeId] = useState(null); + + const fetchKeys = useCallback(async () => { + setIsLoading(true); + try { + const res = await fetch("/api/keys", { cache: "no-store" }); + if (res.ok) { + const data = await res.json(); + setKeys(data); + } + } catch (error) { + console.error("Failed to fetch API keys:", error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchKeys(); + }, [fetchKeys]); + + const copyToClipboard = async (text: string, field: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + } catch { + console.error("Failed to copy"); + } + }; + + const handleCreate = async () => { + setIsCreating(true); + try { + const res = await fetch("/api/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newKeyName.trim() || undefined }), + }); + if (res.ok) { + const data: NewKeyResponse = await res.json(); + setNewlyCreatedKey(data); + setShowCreateForm(false); + setNewKeyName(""); + fetchKeys(); + } + } catch (error) { + console.error("Failed to create API key:", error); + } finally { + setIsCreating(false); + } + }; + + const handleRevoke = async (id: string) => { + setRevokingId(id); + try { + const res = await fetch(`/api/keys/${id}`, { method: "DELETE" }); + if (res.ok) { + setConfirmRevokeId(null); + fetchKeys(); + } + } catch (error) { + console.error("Failed to revoke API key:", error); + } finally { + setRevokingId(null); + } + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + return ( +
+
+
+

API Keys

+

+ Manage API keys for SDK authentication +

+
+ +
+ + {newlyCreatedKey && ( +
+
+
+ +
+
+

+ API Key Created +

+

+ {newlyCreatedKey.name} +

+
+
+ +
+
+ {newlyCreatedKey.key} +
+ +
+ +
+ +

+ This key won't be shown again. Copy it now and store it + securely. +

+
+ + +
+ )} + + {showCreateForm && !newlyCreatedKey && ( +
+
+ +

Create New API Key

+
+
+ + setNewKeyName(e.target.value)} + placeholder="e.g. Production, Staging, CI/CD" + className="w-full px-4 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-emerald-500/40 focus:ring-1 focus:ring-emerald-500/20 transition-colors" + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + autoFocus + /> +
+
+ + +
+
+ )} + +
+
+ +

Active Keys

+
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : keys.length === 0 ? ( +
+
+ +
+

+ No API keys yet +

+

+ Create one to authenticate your SDK +

+
+ ) : ( +
+ {keys.map((apiKey) => ( +
+
+ +
+ +
+

+ {apiKey.name} +

+
+ + {apiKey.keyPrefix}•••••••• + + + Created {formatDate(apiKey.createdAt)} + + {apiKey.lastUsedAt && ( + + Last used {formatDate(apiKey.lastUsedAt)} + + )} +
+
+ + {confirmRevokeId === apiKey.id ? ( +
+ + +
+ ) : ( + + )} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx index 50d4673..33738e9 100644 --- a/apps/web/src/app/dashboard/layout.tsx +++ b/apps/web/src/app/dashboard/layout.tsx @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation"; import { Activity, GitBranch, + Key, Settings, Menu, ChevronRight, @@ -22,6 +23,7 @@ interface NavItem { const navItems: NavItem[] = [ { href: "/dashboard", label: "Traces", icon: Activity }, { href: "/dashboard/decisions", label: "Decisions", icon: GitBranch }, + { href: "/dashboard/keys", label: "API Keys", icon: Key }, { href: "/dashboard/settings", label: "Settings", icon: Settings }, ]; diff --git a/apps/web/src/app/dashboard/settings/page.tsx b/apps/web/src/app/dashboard/settings/page.tsx index 0bd7daa..1dc539e 100644 --- a/apps/web/src/app/dashboard/settings/page.tsx +++ b/apps/web/src/app/dashboard/settings/page.tsx @@ -11,6 +11,13 @@ import { Database, Trash2, AlertTriangle, + CreditCard, + Crown, + Zap, + ArrowUpRight, + User, + Calendar, + Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -21,12 +28,71 @@ interface Stats { totalEvents: number; } +interface AccountData { + id: string; + email: string; + name: string | null; + createdAt: string; + subscription: { + tier: "FREE" | "STARTER" | "PRO"; + status: "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID"; + sessionsUsed: number; + sessionsLimit: number; + currentPeriodStart: string | null; + currentPeriodEnd: string | null; + hasStripeSubscription: boolean; + } | null; +} + +const TIERS = [ + { + key: "FREE" as const, + name: "Free", + price: 0, + period: "day", + sessions: 20, + description: "For getting started", + features: ["20 sessions per day", "Basic trace viewing", "Community support"], + }, + { + key: "STARTER" as const, + name: "Starter", + price: 5, + period: "month", + sessions: 1000, + description: "For small teams", + features: [ + "1,000 sessions per month", + "Advanced analytics", + "Priority support", + ], + }, + { + key: "PRO" as const, + name: "Pro", + price: 20, + period: "month", + sessions: 100000, + description: "For production workloads", + features: [ + "100,000 sessions per month", + "Full analytics suite", + "Dedicated support", + "Custom retention", + ], + }, +]; + export default function SettingsPage() { const [stats, setStats] = useState(null); + const [account, setAccount] = useState(null); const [isLoadingStats, setIsLoadingStats] = useState(true); + const [isLoadingAccount, setIsLoadingAccount] = useState(true); const [copiedField, setCopiedField] = useState(null); const [isPurging, setIsPurging] = useState(false); const [showPurgeConfirm, setShowPurgeConfirm] = useState(false); + const [upgradingTier, setUpgradingTier] = useState(null); + const [isOpeningPortal, setIsOpeningPortal] = useState(false); const fetchStats = useCallback(async () => { setIsLoadingStats(true); @@ -43,9 +109,25 @@ export default function SettingsPage() { } }, []); + const fetchAccount = useCallback(async () => { + setIsLoadingAccount(true); + try { + const res = await fetch("/api/settings/account", { cache: "no-store" }); + if (res.ok) { + const data = await res.json(); + setAccount(data); + } + } catch (error) { + console.error("Failed to fetch account:", error); + } finally { + setIsLoadingAccount(false); + } + }, []); + useEffect(() => { fetchStats(); - }, [fetchStats]); + fetchAccount(); + }, [fetchStats, fetchAccount]); const copyToClipboard = async (text: string, field: string) => { try { @@ -72,6 +154,48 @@ export default function SettingsPage() { } }; + const handleUpgrade = async (tierKey: string) => { + setUpgradingTier(tierKey); + try { + const res = await fetch("/api/stripe/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tierKey }), + }); + const data = await res.json(); + if (data.url) { + window.location.href = data.url; + } + } catch (error) { + console.error("Failed to create checkout:", error); + } finally { + setUpgradingTier(null); + } + }; + + const handleManageSubscription = async () => { + setIsOpeningPortal(true); + try { + const res = await fetch("/api/stripe/portal", { method: "POST" }); + const data = await res.json(); + if (data.url) { + window.location.href = data.url; + } + } catch (error) { + console.error("Failed to open portal:", error); + } finally { + setIsOpeningPortal(false); + } + }; + + const currentTier = account?.subscription?.tier ?? "FREE"; + const sessionsUsed = account?.subscription?.sessionsUsed ?? 0; + const sessionsLimit = account?.subscription?.sessionsLimit ?? 20; + const usagePercent = + sessionsLimit > 0 + ? Math.min((sessionsUsed / sessionsLimit) * 100, 100) + : 0; + const endpointUrl = typeof window !== "undefined" ? `${window.location.origin}/api/traces` @@ -82,10 +206,225 @@ export default function SettingsPage() {

Settings

- Configuration and SDK connection details + Account, billing, and configuration

+ {/* Account */} +
+
+ +

Account

+
+ +
+ {isLoadingAccount ? ( +
+ + Loading account... +
+ ) : account ? ( +
+
+

+ Email +

+

+ {account.email} +

+
+
+

+ Name +

+

+ {account.name ?? "\u2014"} +

+
+
+

+ Member since +

+
+ + {new Date(account.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +
+
+
+ ) : ( +

+ Unable to load account info +

+ )} +
+
+ + {/* Subscription & Billing */} +
+
+ +

Subscription & Billing

+
+ +
+
+
+ Current plan + + {currentTier === "PRO" && } + {currentTier === "STARTER" && } + {currentTier} + +
+ {currentTier !== "FREE" && + account?.subscription?.hasStripeSubscription && ( + + )} +
+ +
+
+ + {sessionsUsed.toLocaleString()} of{" "} + {sessionsLimit.toLocaleString()} sessions used + + + {currentTier === "FREE" + ? "20 sessions/day" + : "This billing period"} + +
+
+
90 ? "bg-amber-500" : "bg-emerald-500" + )} + style={{ width: `${usagePercent}%` }} + /> +
+ {currentTier !== "FREE" && + account?.subscription?.currentPeriodStart && + account?.subscription?.currentPeriodEnd && ( +

+ Period:{" "} + {new Date( + account.subscription.currentPeriodStart + ).toLocaleDateString()}{" "} + \u2014{" "} + {new Date( + account.subscription.currentPeriodEnd + ).toLocaleDateString()} +

+ )} +
+
+ +
+ {TIERS.map((tier) => { + const isCurrent = currentTier === tier.key; + const tierOrder = { FREE: 0, STARTER: 1, PRO: 2 }; + const isUpgrade = tierOrder[tier.key] > tierOrder[currentTier]; + const isDowngrade = tierOrder[tier.key] < tierOrder[currentTier]; + + return ( +
+ {isCurrent && ( +
+ + Current + +
+ )} + +
+

+ {tier.name} +

+

+ {tier.description} +

+
+ +
+ + ${tier.price} + + + /{tier.period} + +
+ +
    + {tier.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + {isCurrent ? ( +
+ Active plan +
+ ) : isUpgrade ? ( + + ) : isDowngrade ? ( + + ) : null} +
+ ); + })} +
+
+ {/* SDK Connection */}
@@ -102,25 +441,17 @@ export default function SettingsPage() { onCopy={copyToClipboard} /> - -
-

Quick start

-
-
{`from agentlens import init
-
-init(
-    api_key="your-api-key",
-    endpoint="${endpointUrl.replace("/api/traces", "")}",
-)`}
-
+

API Key

+

+ Manage your API keys from the{" "} + + API Keys page + +

@@ -275,9 +606,7 @@ function SettingField({ )} - {hint && ( -

{hint}

- )} + {hint &&

{hint}

} ); } diff --git a/apps/web/src/app/docs/getting-started/page.tsx b/apps/web/src/app/docs/getting-started/page.tsx index e3b395e..b88f3e3 100644 --- a/apps/web/src/app/docs/getting-started/page.tsx +++ b/apps/web/src/app/docs/getting-started/page.tsx @@ -40,7 +40,26 @@ export default function GettingStartedPage() { ) -
  • An API key for authentication
  • +
  • + An AgentLens account —{" "} + + sign up here + {" "} + if you haven{"'"}t already +
  • +
  • + An API key (create one in{" "} + + Dashboard → API Keys + + ) +
  • @@ -62,6 +81,23 @@ export default function GettingStartedPage() {

    Step 2: Initialize AgentLens

    +

    + Sign up at{" "} + + agentlens.vectry.tech + + , then go to{" "} + + Dashboard → API Keys + {" "} + to create your key. Pass it to the SDK during initialization: +

    Python

    {`import agentlens diff --git a/apps/web/src/app/docs/python-sdk/page.tsx b/apps/web/src/app/docs/python-sdk/page.tsx index b2a2f40..08ee659 100644 --- a/apps/web/src/app/docs/python-sdk/page.tsx +++ b/apps/web/src/app/docs/python-sdk/page.tsx @@ -50,7 +50,7 @@ export default function PythonSdkPage() {

    Parameters @@ -70,7 +70,7 @@ export default function PythonSdkPage() { api_key str required - Your AgentLens API key + Your AgentLens API key (from Dashboard → API Keys) endpoint diff --git a/apps/web/src/app/docs/typescript-sdk/page.tsx b/apps/web/src/app/docs/typescript-sdk/page.tsx index 9fc431a..71e2068 100644 --- a/apps/web/src/app/docs/typescript-sdk/page.tsx +++ b/apps/web/src/app/docs/typescript-sdk/page.tsx @@ -52,7 +52,7 @@ export default function TypeScriptSdkPage() {

    Options @@ -72,7 +72,7 @@ export default function TypeScriptSdkPage() { apiKey string required - Your AgentLens API key + Your AgentLens API key (from Dashboard → API Keys) endpoint diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index d81b155..2928da8 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import { Inter } from "next/font/google"; import type { Metadata } from "next"; +import { SessionProvider } from "next-auth/react"; import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); @@ -72,7 +73,7 @@ export default function RootLayout({ return ( - {children} + {children} ); diff --git a/apps/web/src/auth.config.ts b/apps/web/src/auth.config.ts new file mode 100644 index 0000000..fba0065 --- /dev/null +++ b/apps/web/src/auth.config.ts @@ -0,0 +1,9 @@ +import type { NextAuthConfig } from "next-auth"; + +export default { + providers: [], + session: { strategy: "jwt" }, + pages: { + signIn: "/login", + }, +} satisfies NextAuthConfig; diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts new file mode 100644 index 0000000..41a0fa4 --- /dev/null +++ b/apps/web/src/auth.ts @@ -0,0 +1,72 @@ +import NextAuth from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import { compare } from "bcryptjs"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; +import authConfig from "./auth.config"; + +declare module "next-auth" { + interface Session { + user: { + id: string; + email: string; + name?: string | null; + image?: string | null; + }; + } +} + +declare module "@auth/core/jwt" { + interface JWT { + id: string; + } +} + +const loginSchema = z.object({ + email: z.email("Invalid email address"), + password: z.string().min(8, "Password must be at least 8 characters"), +}); + +export const { handlers, auth, signIn, signOut } = NextAuth({ + ...authConfig, + providers: [ + Credentials({ + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const parsed = loginSchema.safeParse(credentials); + if (!parsed.success) return null; + + const { email, password } = parsed.data; + + const user = await prisma.user.findUnique({ + where: { email: email.toLowerCase() }, + }); + if (!user) return null; + + const isValid = await compare(password, user.passwordHash); + if (!isValid) return null; + + return { + id: user.id, + email: user.email, + name: user.name, + }; + }, + }), + ], + callbacks: { + jwt({ token, user }) { + if (user) { + token.id = user.id as string; + } + return token; + }, + session({ session, token }) { + session.user.id = token.id; + return session; + }, + }, +}); diff --git a/apps/web/src/lib/api-key.ts b/apps/web/src/lib/api-key.ts new file mode 100644 index 0000000..811740f --- /dev/null +++ b/apps/web/src/lib/api-key.ts @@ -0,0 +1,33 @@ +import { createHash } from "crypto"; +import { prisma } from "@/lib/prisma"; + +export async function validateApiKey(bearerToken: string) { + const keyHash = createHash("sha256").update(bearerToken).digest("hex"); + + const apiKey = await prisma.apiKey.findFirst({ + where: { keyHash, revoked: false }, + include: { + user: { + include: { + subscription: true, + }, + }, + }, + }); + + if (!apiKey) return null; + + prisma.apiKey + .update({ + where: { id: apiKey.id }, + data: { lastUsedAt: new Date() }, + }) + .catch(() => {}); + + return { + userId: apiKey.userId, + user: apiKey.user, + subscription: apiKey.user.subscription, + apiKey, + }; +} diff --git a/apps/web/src/lib/stripe.ts b/apps/web/src/lib/stripe.ts new file mode 100644 index 0000000..e3e1597 --- /dev/null +++ b/apps/web/src/lib/stripe.ts @@ -0,0 +1,35 @@ +import Stripe from "stripe"; + +let _stripe: Stripe | null = null; + +export function getStripe(): Stripe { + if (!_stripe) { + const key = process.env.STRIPE_SECRET_KEY; + if (!key) throw new Error("STRIPE_SECRET_KEY is not set"); + _stripe = new Stripe(key, { apiVersion: "2026-01-28.clover" }); + } + return _stripe; +} + +export const TIER_CONFIG = { + FREE: { + name: "Free", + sessionsLimit: 20, + period: "day", + price: 0, + }, + STARTER: { + name: "Starter", + priceId: process.env.STRIPE_STARTER_PRICE_ID!, + sessionsLimit: 1000, + period: "month", + price: 5, + }, + PRO: { + name: "Pro", + priceId: process.env.STRIPE_PRO_PRICE_ID!, + sessionsLimit: 100000, + period: "month", + price: 20, + }, +} as const; diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts new file mode 100644 index 0000000..b76baa0 --- /dev/null +++ b/apps/web/src/middleware.ts @@ -0,0 +1,50 @@ +import NextAuth from "next-auth"; +import { NextResponse } from "next/server"; +import authConfig from "./auth.config"; + +const { auth } = NextAuth(authConfig); + +const publicPaths = [ + "/", + "/docs", + "/api/auth", + "/api/traces", + "/api/health", +]; + +function isPublicPath(pathname: string): boolean { + return publicPaths.some( + (p) => pathname === p || pathname.startsWith(`${p}/`) + ); +} + +export default auth((req) => { + const { pathname } = req.nextUrl; + const isLoggedIn = !!req.auth; + + if (isPublicPath(pathname)) { + if (isLoggedIn && (pathname === "/login" || pathname === "/register")) { + return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin)); + } + return NextResponse.next(); + } + + if (pathname === "/login" || pathname === "/register") { + if (isLoggedIn) { + return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin)); + } + return NextResponse.next(); + } + + if (pathname.startsWith("/dashboard") && !isLoggedIn) { + const loginUrl = new URL("/login", req.nextUrl.origin); + loginUrl.searchParams.set("callbackUrl", pathname); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +}); + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|og-image.png).*)"], +}; diff --git a/docker-compose.yml b/docker-compose.yml index 44026a3..0252a20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,12 @@ services: - NODE_ENV=production - REDIS_URL=redis://redis:6379 - DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens + - AUTH_SECRET=Ge0Gh6bObko0Gdrzv+l0qKHgvut3M7Av8mDFQG9fYzs= + - AUTH_TRUST_HOST=true + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} + - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} + - STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-} depends_on: redis: condition: service_started @@ -63,7 +69,7 @@ services: build: context: . target: builder - command: npx prisma migrate deploy --schema=packages/database/prisma/schema.prisma + command: npx prisma db push --schema=packages/database/prisma/schema.prisma --skip-generate environment: - DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens depends_on: diff --git a/package-lock.json b/package-lock.json index e26ce5c..f4bf21e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,14 +24,19 @@ "@agentlens/database": "*", "@dagrejs/dagre": "^2.0.4", "@xyflow/react": "^12.10.0", + "bcryptjs": "^3.0.3", "lucide-react": "^0.469.0", "next": "^15.1.0", + "next-auth": "^5.0.0-beta.30", "react": "^19.0.0", "react-dom": "^19.0.0", - "shiki": "^3.22.0" + "shiki": "^3.22.0", + "stripe": "^20.3.1", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.0", + "@types/bcryptjs": "^2.4.6", "@types/dagre": "^0.7.53", "@types/node": "^22.0.0", "@types/react": "^19.0.0", @@ -41,6 +46,15 @@ "typescript": "^5.7" } }, + "apps/web/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@agentlens/database": { "resolved": "packages/database", "link": true @@ -62,6 +76,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", + "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@dagrejs/dagre": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz", @@ -1197,6 +1240,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@prisma/client": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", @@ -1986,6 +2038,13 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -2071,7 +2130,7 @@ "version": "22.19.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz", "integrity": "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2165,6 +2224,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -2777,6 +2845,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -3328,6 +3405,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.30", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", + "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -3388,6 +3492,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz", + "integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3553,6 +3666,25 @@ } } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prisma": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", @@ -3854,6 +3986,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stripe": { + "version": "20.3.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz", + "integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -4131,7 +4280,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unist-util-is": { @@ -4300,7 +4449,7 @@ }, "packages/opencode-plugin": { "name": "opencode-agentlens", - "version": "0.1.0", + "version": "0.1.6", "license": "MIT", "dependencies": { "agentlens-sdk": "*" @@ -4376,7 +4525,7 @@ }, "packages/sdk-ts": { "name": "agentlens-sdk", - "version": "0.1.0", + "version": "0.1.3", "license": "MIT", "devDependencies": { "tsup": "^8.3.0", diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 61cc1a3..0c15eac 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -7,6 +7,82 @@ generator client { provider = "prisma-client-js" } +// ─── Auth & Billing ──────────────────────────────────────────── + +model User { + id String @id @default(cuid()) + email String @unique + passwordHash String + name String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + subscription Subscription? + apiKeys ApiKey[] + traces Trace[] + + @@index([email]) +} + +model ApiKey { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + name String @default("Default") + keyHash String @unique // SHA-256 hash of the actual key + keyPrefix String // First 8 chars for display: "al_xxxx..." + lastUsedAt DateTime? + + revoked Boolean @default(false) + createdAt DateTime @default(now()) + + @@index([keyHash]) + @@index([userId]) +} + +model Subscription { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + tier SubscriptionTier @default(FREE) + stripeCustomerId String? @unique + stripeSubscriptionId String? @unique + stripePriceId String? + + currentPeriodStart DateTime? + currentPeriodEnd DateTime? + + // Usage tracking for the current billing period + sessionsUsed Int @default(0) + sessionsLimit Int @default(20) // Free tier: 20/day, paid: per month + + status SubscriptionStatus @default(ACTIVE) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([stripeCustomerId]) + @@index([stripeSubscriptionId]) +} + +enum SubscriptionTier { + FREE // 20 sessions/day + STARTER // $5/mo — 1,000 sessions/mo + PRO // $20/mo — 100,000 sessions/mo +} + +enum SubscriptionStatus { + ACTIVE + PAST_DUE + CANCELED + UNPAID +} + +// ─── Observability ───────────────────────────────────────────── + model Trace { id String @id @default(cuid()) sessionId String? @@ -15,6 +91,10 @@ model Trace { tags String[] @default([]) metadata Json? + // Owner — nullable for backward compat with existing unowned traces + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + totalCost Float? totalTokens Int? totalDuration Int? @@ -32,6 +112,7 @@ model Trace { @@index([status]) @@index([createdAt]) @@index([name]) + @@index([userId]) } model DecisionPoint {