SE_URL")
}
model AppIdentity {
id String @id @default(cuid())
email String @unique
whopId String @unique
createdAt DateTime @default(now())
grants AccessGrant[]
outputs OutputRecord[]
}
model BillingTier {
id String @id @default(cuid())
name String // "FREE" | "PRO"
dailyLimit Int // 5 for FREE, -1 for unlimited
templateCap Int // 3 for FREE, 8 for PRO
grants AccessGrant[]
}
model AccessGrant {
id String @id @default(cuid())
userId String
tierId String
status String // "ACTIVE" | "EXPIRED" | "CANCELLED"
activatedAt DateTime @default(now())
user AppIdentity @relation(fields: [userId], references: [id])
tier BillingTier @relation(fields: [tierId], references: [id])
}
model PromptBlueprint {
id String @id @default(cuid())
slug String @unique
label String
isProOnly Boolean @default(false)
outputs OutputRecord[]
}
model OutputRecord {
id String @id @default(cuid())
userId String
blueprintId String
prompt String
model String // "claude-3-5-sonnet" | "gpt-4o"
createdAt DateTime @default(now())
user AppIdentity @relation(fields: [userId], references: [id])
blueprint PromptBlueprint @relation(fields: [blueprintId], references: [id])
nodes ConversationNode[]
}
model ConversationNode {
id String @id @default(cuid())
outputId String
role String // "user" | "assistant"
content String
output OutputRecord @relation(fields: [outputId], references: [id])
}
### Step 2: Encrypted Session Management
Replace Redis-based sessions with `iron-session` for zero-infrastructure state management. Encrypted cookies eliminate server-side session storage while maintaining security through cryptographic signing.
```typescript
// lib/session.ts
import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";
export interface SessionData {
whopId: string;
email: string;
tierStatus: "ACTIVE" | "INACTIVE";
}
const sessionOptions = {
cookieName: "ai_ssa_session",
password: process.env.IRON_SESSION_SECRET!,
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
},
};
export const getSession = withIronSessionSsr(async (req: NextApiRequest, res: NextApiResponse) => {
return req.session;
});
export const protectRoute = (handler: (req: NextApiRequest, res: NextApiResponse) => Promise<void>) => {
return withIronSessionApiRoute(async (req, res) => {
if (!req.session.whopId) {
return res.status(401).json({ error: "Unauthorized" });
}
return handler(req, res);
}, sessionOptions);
};
Step 3: AI Generation Pipeline
Use the Vercel AI SDK to abstract model selection and streaming. This approach allows runtime switching between Claude and ChatGPT without rewriting inference logic.
// app/api/generate/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { anthropic } from "@ai-sdk/anthropic";
import { getSession } from "@/lib/session";
import { prisma } from "@/lib/db";
export async function POST(req: Request) {
const session = await getSession(req, res);
const { blueprintSlug, userInput, model } = await req.json();
// Tier enforcement
const activeGrant = await prisma.accessGrant.findFirst({
where: { userId: session.whopId, status: "ACTIVE" },
include: { tier: true },
});
if (!activeGrant) {
return new Response("Subscription required", { status: 403 });
}
// Blueprint access check
const blueprint = await prisma.promptBlueprint.findUnique({ where: { slug: blueprintSlug } });
if (blueprint?.isProOnly && activeGrant.tier.name !== "PRO") {
return new Response("Pro tier required", { status: 403 });
}
// Model routing
const provider = model === "claude" ? anthropic : openai;
const modelName = model === "claude" ? "claude-3-5-sonnet-20241022" : "gpt-4o";
const result = streamText({
model: provider(modelName),
prompt: `Template: ${blueprint?.label}\nUser Input: ${userInput}\nGenerate a structured draft.`,
maxTokens: 1024,
});
// Persist generation record
await prisma.outputRecord.create({
data: {
userId: session.whopId,
blueprintId: blueprint!.id,
prompt: userInput,
model: modelName,
},
});
return result.toDataStreamResponse();
}
Step 4: Webhook-Driven Billing Sync
Handle Whop membership events to maintain subscription state without polling. Webhook handlers must verify signatures and process events idempotently.
// app/api/webhooks/whop/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import crypto from "crypto";
export async function POST(req: NextRequest) {
const payload = await req.text();
const signature = req.headers.get("x-whop-signature")!;
// Verify webhook authenticity
const expected = crypto
.createHmac("sha256", process.env.WHOP_WEBHOOK_SECRET!)
.update(payload)
.digest("hex");
if (signature !== expected) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(payload);
if (event.type === "membership.activated") {
const { user_id, tier_id } = event.data;
await prisma.accessGrant.upsert({
where: { userId: user_id },
update: { status: "ACTIVE", tierId: tier_id },
create: {
userId: user_id,
tierId: tier_id,
status: "ACTIVE",
},
});
}
if (event.type === "membership.expired") {
const { user_id } = event.data;
await prisma.accessGrant.updateMany({
where: { userId: user_id, status: "ACTIVE" },
data: { status: "EXPIRED" },
});
}
return NextResponse.json({ received: true });
}
Architecture Decisions & Rationale
- Deploy-First Workflow: OAuth 2.1/PKCE requires stable callback URLs. Deploying to Vercel before implementing auth routes prevents callback mismatches and eliminates redirect configuration drift.
- iron-session over Redis: Encrypted cookies remove infrastructure dependencies, reduce latency, and simplify horizontal scaling. Session data remains cryptographically signed and tamper-proof.
- Vercel AI SDK Abstraction: Single interface for streaming, model routing, and token counting. Switching between Claude and ChatGPT requires only a parameter change, not a rewrite.
- Webhook-Driven State Sync: Subscription status is updated asynchronously via Whop events. The application reads grant status on each request, ensuring tier enforcement stays consistent without cron jobs.
- Capped History Storage: Limiting stored generations to 20 per user controls database growth and aligns with UX patterns that prioritize recent context over archival retrieval.
Pitfall Guide
1. Hardcoding OAuth Callbacks Before Deployment
Explanation: OAuth providers reject callback URLs that don't match registered domains. Local development URLs (localhost:3000) differ from production, causing token exchange failures.
Fix: Deploy to Vercel first, register the production URL in Whop OAuth settings, then implement the callback route. Use environment variables to separate dev/prod callback paths.
2. Ignoring Webhook Idempotency
Explanation: Payment platforms retry webhook deliveries on network failures. Processing the same membership.activated event twice can create duplicate records or overwrite active subscriptions.
Fix: Implement idempotency keys or upsert logic. Check existing grant status before updating. Log event IDs to track duplicate deliveries.
3. Unbounded AI Cost Exposure
Explanation: Streaming responses without token limits or rate controls can exhaust API budgets during traffic spikes or prompt injection attacks.
Fix: Enforce maxTokens at the SDK level, implement daily generation caps per tier, and add middleware rate limiting (/api/generate). Monitor usage via provider dashboards.
4. Mixing Server and Client Tier State
Explanation: Storing subscription status in client-side React state allows users to bypass tier checks by manipulating local variables.
Fix: Always verify AccessGrant status on the server before executing AI routes. Use middleware or route handlers to reject requests when tierStatus !== "ACTIVE".
5. Neglecting Session Encryption Rotation
Explanation: Static IRON_SESSION_SECRET values become vulnerable if leaked. Long-lived secrets increase the window for session forgery.
Fix: Rotate secrets quarterly. Store secrets in Vercel environment variables, not code. Implement automatic session invalidation on secret rotation by appending version numbers to cookie names.
6. Over-Fetching Conversation History
Explanation: Loading full chat threads for every generation request increases payload size and latency, especially as conversations grow.
Fix: Implement cursor-based pagination for ConversationNode retrieval. Cap initial loads to the last 5 messages, fetching older context on demand.
7. Assuming Guaranteed Webhook Delivery
Explanation: Network partitions or server downtime can cause missed webhook events, leaving subscription states out of sync.
Fix: Implement a reconciliation cron job that queries Whop's membership API daily. Compare local AccessGrant status with provider records and correct discrepancies.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Solo founder, <100 users | iron-session + Whop OAuth/Payments | Zero infra, rapid deployment, provider-managed compliance | Low ($0 infra, Whop transaction fees) |
| Growing SaaS, 1k-10k users | Redis sessions + Stripe + custom webhooks | Fine-grained control, advanced billing features, audit trails | Medium (Redis hosting, Stripe 2.9% + $0.30) |
| Enterprise AI tool, compliance required | Auth0/Clerk + Stripe Billing + dedicated webhook service | SSO, SOC2 compliance, advanced role management, SLA guarantees | High (Auth0/Clerk tiers, Stripe enterprise pricing) |
Configuration Template
# .env.example
DATABASE_URL="postgresql://neon_owner:password@ep-cool-shadow-123456.us-east-2.aws.neon.tech/ai_ssa_db?sslmode=require"
IRONS_SESSION_SECRET="generate-32-char-random-string-here"
WHOP_CLIENT_ID="whp_xxxxxxxxxxxxxxxx"
WHOP_CLIENT_SECRET="whs_xxxxxxxxxxxxxxxx"
WHOP_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxx"
OPENAI_API_KEY="sk-xxxxxxxxxxxxxxxx"
ANTHROPIC_API_KEY="sk-ant-xxxxxxxxxxxxxxxx"
VERCEL_URL="https://your-app.vercel.app"
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const session = req.cookies.get("ai_ssa_session")?.value;
const isProtected = req.nextUrl.pathname.startsWith("/studio");
if (isProtected && !session) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/studio/:path*"],
};
Quick Start Guide
- Initialize Project: Run
npx create-next-app@latest ai-ssa --typescript --tailwind --app. Install dependencies: npm i @prisma/client prisma ai @ai-sdk/openai @ai-sdk/anthropic iron-session @whop/next.
- Configure Database: Run
npx prisma init, replace the schema with the provided Prisma model, then execute npx prisma migrate dev --name init.
- Deploy & Register: Push to GitHub, connect to Vercel, and deploy. Copy the production URL to Whop OAuth settings as the callback endpoint.
- Seed & Test: Run
npx prisma db seed to populate tiers and templates. Visit /studio to verify OAuth flow, tier enforcement, and AI generation streaming.