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 (
+
+ );
+}
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
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+ 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 && (
+
+
+
+
+ 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 {