Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
263949b
docs: expand CLAUDE.md with additional tooling details
claude Apr 22, 2026
d3a564a
feat(billing): add stripe dependency and env vars
claude May 19, 2026
d778774
feat(db): add customers and subscriptions tables
claude May 19, 2026
477f61d
feat(billing): add stripe client, checkout helper, and webhook handlers
claude May 19, 2026
ba9133c
feat(billing): add stripe webhook route handler
claude May 19, 2026
f35b156
feat(billing): add /pricing route and checkout server action
claude May 19, 2026
6d126ed
feat(billing): test webhook signature verification and document setup
claude May 19, 2026
bcd280c
feat(auth): add drizzle adapter, resend, and simplewebauthn deps
claude May 19, 2026
cd092c4
feat(db): add auth.js drizzle adapter tables and extend users
claude May 19, 2026
15ea897
feat(email): add resend client and sendEmail helper
claude May 19, 2026
26af1da
feat(auth): wire drizzle adapter, resend magic link, and passkey
claude May 19, 2026
021eab9
feat(auth): add /signin page with passkey and magic-link flows
claude May 19, 2026
9d61685
docs(auth): document the auth + email stack in CLAUDE.md
claude May 19, 2026
b462dbc
feat(email): add react-email libraries and preview script
claude May 21, 2026
dba12cf
feat(email): add react-email templates for magic link and welcome
claude May 21, 2026
a6a0151
feat(email): render react templates via sendEmail and use in auth
claude May 21, 2026
1828bfd
feat(observability): add structured logger
claude May 21, 2026
e88b957
feat(observability): add posthog analytics (server + client)
claude May 21, 2026
d50fd9e
feat(observability): add feature-flag primitive
claude May 21, 2026
f659121
feat(observability): add instrumentation hook and document the stack
claude May 21, 2026
6723488
feat(ai): add anthropic sdk dependency and env var
claude May 21, 2026
d154038
feat(ai): add lazy anthropic client
claude May 21, 2026
bf12f90
feat(ai): add persona and register tone-preset layer
claude May 21, 2026
e958516
feat(ai): add cache-ready system composer and generate helpers
claude May 21, 2026
d7e4c81
feat(ai): add animation-state vocabulary and barrel
claude May 21, 2026
7edadb9
feat(ai): test compose/animation logic and document the module
claude May 21, 2026
a359ca2
chore: gitignore local-only signal/profile files
claude May 21, 2026
f84342a
fix(deps): patch drizzle-orm SQL-injection advisory
claude May 21, 2026
0e31d26
fix(ci): commit bun.lock and align dependency floors
claude May 21, 2026
59bb459
refactor(lib): extract shared lazy-client factory
claude May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ DATABASE_URL=local.db

# Auth.js — generate with: openssl rand -base64 32
AUTH_SECRET=
# Magic-link sender address (must be a verified Resend domain in prod)
AUTH_EMAIL_FROM=

# Resend — https://resend.com/api-keys
RESEND_API_KEY=

# Stripe — keys from https://dashboard.stripe.com/test/apikeys
# STRIPE_WEBHOOK_SECRET is printed by `stripe listen --forward-to localhost:3000/api/webhooks/stripe`
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ID=

# AI — Anthropic / Claude (https://console.anthropic.com/settings/keys)
ANTHROPIC_API_KEY=

# Observability
# LOG_LEVEL: debug | info | warn | error (default info)
# LOG_LEVEL=info
# PostHog — analytics + feature flags (https://posthog.com)
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

# Skip env validation during build (CI/Docker)
# SKIP_ENV_VALIDATION=1
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ yarn-error.log*
.env*.local
.env

# local-only signal/profile files (gitignored by design)
*.local.yaml
*.local.yml

# vercel
.vercel

Expand All @@ -34,7 +38,7 @@ yarn-error.log*
next-env.d.ts

# bun
bun.lock
# bun.lock is committed: CI runs `bun install --frozen-lockfile`, which needs it.

# playwright
/test-results/
Expand Down
290 changes: 202 additions & 88 deletions CLAUDE.md

Large diffs are not rendered by default.

2,170 changes: 2,170 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript", "prettier"),
{
ignores: [".next/**", "out/**", "build/**", "coverage/**"],
ignores: [".next/**", "out/**", "build/**", "coverage/**", "next-env.d.ts"],
},
];

Expand Down
90 changes: 51 additions & 39 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,58 +23,70 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:migrate": "drizzle-kit migrate",
"email:dev": "email dev --dir src/emails --port 3001",
"clean": "rm -rf .next out node_modules",
"validate": "bun run lint && bun run type-check && bun run test:run && bun run build",
"prepare": "lefthook install"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-slot": "^1.1.1",
"@anthropic-ai/sdk": "^0.97.1",
"@auth/drizzle-adapter": "^1.11.2",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-slot": "^1.2.4",
"@react-email/components": "^1.0.12",
"@react-email/render": "^2.0.8",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-query": "^5.100.11",
"better-sqlite3": "^11.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.38.3",
"better-sqlite3": "^11.7.0",
"drizzle-orm": "^0.45.2",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.25",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"tailwind-merge": "^2.6.0",
"tw-animate-css": "^1.2.5",
"zod": "^3.24.1",
"zustand": "^5.0.2"
"next": "^15.5.18",
"next-auth": "^5.0.0-beta.31",
"posthog-js": "^1.375.0",
"posthog-node": "^5.35.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.76.0",
"resend": "^6.12.3",
"stripe": "^22.1.1",
"tailwind-merge": "^2.6.1",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.76",
"zustand": "^5.0.13"
},
"devDependencies": {
"@commitlint/cli": "^19.6.1",
"@commitlint/config-conventional": "^19.6.0",
"@eslint/js": "^9.17.0",
"@playwright/test": "^1.49.1",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.5",
"@types/react": "^19.0.3",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"drizzle-kit": "^0.30.4",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"eslint-config-prettier": "^9.1.0",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.3.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.19",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.4",
"eslint-config-next": "^15.5.18",
"eslint-config-prettier": "^9.1.2",
"jsdom": "^25.0.1",
"lefthook": "^1.10.10",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"lefthook": "^1.13.6",
"postcss": "^8.5.15",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.6.14",
"react-email": "^6.3.0",
"tailwindcss": "^4.3.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.8"
"vitest": "^2.1.9"
},
"engines": {
"node": ">=20.0.0"
Expand Down
29 changes: 29 additions & 0 deletions src/app/api/webhooks/stripe/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// @vitest-environment node
// Env vars MUST be set before any import that loads src/lib/env.ts,
// because env validation runs at module load.
process.env.STRIPE_SECRET_KEY = "sk_test_dummy";
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";

import { describe, it, expect } from "vitest";
import { POST } from "./route";

describe("POST /api/webhooks/stripe", () => {
it("returns 400 when the stripe-signature header is missing", async () => {
const req = new Request("http://localhost/api/webhooks/stripe", {
method: "POST",
body: "{}",
});
const res = await POST(req);
expect(res.status).toBe(400);
});

it("returns 400 when the signature is invalid", async () => {
const req = new Request("http://localhost/api/webhooks/stripe", {
method: "POST",
headers: { "stripe-signature": "t=1,v1=deadbeef" },
body: '{"id":"evt_test"}',
});
const res = await POST(req);
expect(res.status).toBe(400);
});
});
31 changes: 31 additions & 0 deletions src/app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { getStripe, handleStripeEvent } from "@/lib/billing";
import { env } from "@/lib/env";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function POST(req: Request): Promise<Response> {
const sig = req.headers.get("stripe-signature");
const secret = env.STRIPE_WEBHOOK_SECRET;
if (!sig || !secret) {
return new NextResponse("Missing signature or secret", { status: 400 });
}

const body = await req.text();
let event;
try {
event = getStripe().webhooks.constructEvent(body, sig, secret);
} catch (err) {
const msg = err instanceof Error ? err.message : "Invalid signature";
return new NextResponse(`Webhook Error: ${msg}`, { status: 400 });
}

try {
await handleStripeEvent(event);
} catch (err) {
console.error("[stripe] handler error", err);
return new NextResponse("Handler error", { status: 500 });
}
return NextResponse.json({ received: true });
}
27 changes: 27 additions & 0 deletions src/app/pricing/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use server";

import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { env } from "@/lib/env";
import { createCheckoutSession } from "@/lib/billing";

export async function startCheckout(): Promise<void> {
const session = await auth();
if (!session?.user?.id || !session.user.email) {
redirect("/api/auth/signin");
}
if (!env.STRIPE_PRICE_ID) {
throw new Error("STRIPE_PRICE_ID is not configured");
}

const appUrl = env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const checkout = await createCheckoutSession({
userId: session.user.id,
email: session.user.email,
priceId: env.STRIPE_PRICE_ID,
successUrl: `${appUrl}/pricing?status=success`,
cancelUrl: `${appUrl}/pricing?status=cancel`,
});

redirect(checkout.url);
}
33 changes: 33 additions & 0 deletions src/app/pricing/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { env } from "@/lib/env";
import { startCheckout } from "./actions";

export default function PricingPage() {
const configured = Boolean(env.STRIPE_PRICE_ID);
Comment thread
bryansayler marked this conversation as resolved.

return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-24">
<h1 className="text-3xl font-bold tracking-tight">Pricing</h1>
<div className="border-border bg-card w-full max-w-md rounded-lg border p-6 shadow-sm">
<h2 className="text-xl font-semibold">Pro</h2>
<p className="text-muted-foreground mt-1 text-sm">
$X / month &mdash; replace with your product copy.
</p>
{configured ? (
<form action={startCheckout} className="mt-6">
<button
type="submit"
className="bg-primary text-primary-foreground hover:bg-primary/90 w-full rounded-md px-4 py-2 text-sm font-medium"
>
Subscribe
</button>
</form>
) : (
<p className="text-muted-foreground mt-6 text-xs">
Configure <code>STRIPE_PRICE_ID</code> in <code>.env</code> to
enable checkout.
</p>
)}
</div>
</main>
);
}
11 changes: 11 additions & 0 deletions src/app/signin/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use server";

import { signIn } from "@/lib/auth";

export async function sendMagicLink(formData: FormData): Promise<void> {
const email = formData.get("email");
if (typeof email !== "string" || !email) {
throw new Error("Email is required");
}
await signIn("resend", { email, redirectTo: "/" });
}
52 changes: 52 additions & 0 deletions src/app/signin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { env } from "@/lib/env";
import { sendMagicLink } from "./actions";
import { PasskeyButton } from "./passkey-button";

export default function SignInPage() {
const emailConfigured = Boolean(env.RESEND_API_KEY && env.AUTH_EMAIL_FROM);

return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<div className="border-border bg-card w-full max-w-sm rounded-lg border p-6 shadow-sm">
<h1 className="text-2xl font-bold tracking-tight">Sign in</h1>
<p className="text-muted-foreground mt-1 text-sm">
Use a passkey or a one-time link.
</p>

<div className="mt-6 space-y-3">
<PasskeyButton />

<div className="text-muted-foreground flex items-center gap-3 text-xs">
<div className="bg-border h-px flex-1" />
or
<div className="bg-border h-px flex-1" />
</div>

{emailConfigured ? (
<form action={sendMagicLink} className="space-y-3">
<input
type="email"
name="email"
required
placeholder="you@example.com"
className="border-input bg-background w-full rounded-md border px-3 py-2 text-sm"
/>
<button
type="submit"
className="bg-primary text-primary-foreground hover:bg-primary/90 w-full rounded-md px-4 py-2 text-sm font-medium"
>
Email me a sign-in link
</button>
</form>
) : (
<p className="text-muted-foreground text-xs">
Configure <code>RESEND_API_KEY</code> and{" "}
<code>AUTH_EMAIL_FROM</code> in <code>.env</code> to enable email
sign-in.
</p>
)}
</div>
</div>
</main>
);
}
26 changes: 26 additions & 0 deletions src/app/signin/passkey-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

import { signIn } from "next-auth/webauthn";
import { useState } from "react";

export function PasskeyButton() {
const [pending, setPending] = useState(false);

return (
<button
type="button"
disabled={pending}
onClick={async () => {
setPending(true);
try {
await signIn("passkey", { redirectTo: "/" });
} finally {
setPending(false);
}
}}
className="border-input hover:bg-accent w-full rounded-md border px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{pending ? "Authenticating…" : "Sign in with a passkey"}
</button>
);
}
Loading