From dba45740a3e5f0bd65a07ca5c97cff1705ccf243 Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 08:23:56 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 12 + README.md | 3 + SKILL.md | 1226 +++++++++++++++++++++++ assets/auth-flow-diagram.md | 370 +++++++ plugin.lock.json | 77 ++ references/cloudflare-worker-drizzle.ts | 334 ++++++ references/cloudflare-worker-kysely.ts | 240 +++++ references/database-schema.ts | 315 ++++++ references/nextjs/README.md | 41 + references/nextjs/postgres-example.ts | 147 +++ references/react-client-hooks.tsx | 470 +++++++++ scripts/setup-d1-drizzle.sh | 221 ++++ 12 files changed, 3456 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 assets/auth-flow-diagram.md create mode 100644 plugin.lock.json create mode 100644 references/cloudflare-worker-drizzle.ts create mode 100644 references/cloudflare-worker-kysely.ts create mode 100644 references/database-schema.ts create mode 100644 references/nextjs/README.md create mode 100644 references/nextjs/postgres-example.ts create mode 100644 references/react-client-hooks.tsx create mode 100644 scripts/setup-d1-drizzle.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..1900076 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "better-auth", + "description": "Build authentication systems for TypeScript/Cloudflare Workers with social auth, 2FA, passkeys, organizations, and RBAC. Self-hosted alternative to Clerk/Auth.js. IMPORTANT: Requires Drizzle ORM or Kysely for D1 - no direct D1 adapter. Use when: self-hosting auth on Cloudflare D1, migrating from Clerk, implementing multi-tenant SaaS, or troubleshooting D1 adapter errors, session serialization, OAuth flows.", + "version": "1.0.0", + "author": { + "name": "Jeremy Dawes", + "email": "jeremy@jezweb.net" + }, + "skills": [ + "./" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..33becfc --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# better-auth + +Build authentication systems for TypeScript/Cloudflare Workers with social auth, 2FA, passkeys, organizations, and RBAC. Self-hosted alternative to Clerk/Auth.js. IMPORTANT: Requires Drizzle ORM or Kysely for D1 - no direct D1 adapter. Use when: self-hosting auth on Cloudflare D1, migrating from Clerk, implementing multi-tenant SaaS, or troubleshooting D1 adapter errors, session serialization, OAuth flows. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..8529f61 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,1226 @@ +--- +name: better-auth +description: | + Build authentication systems for TypeScript/Cloudflare Workers with social auth, 2FA, passkeys, organizations, and RBAC. Self-hosted alternative to Clerk/Auth.js. + + IMPORTANT: Requires Drizzle ORM or Kysely for D1 - no direct D1 adapter. v1.4.0 (Nov 2025) adds stateless sessions, ESM-only (breaking), JWT key rotation, SCIM provisioning. v1.3 adds SSO/SAML, multi-team support. + + Use when: self-hosting auth on Cloudflare D1, migrating from Clerk, implementing multi-tenant SaaS, or troubleshooting D1 adapter errors, session serialization, OAuth flows, TanStack Start cookie issues, nanostore session invalidation. +license: MIT +metadata: + version: 3.0.0 + last_verified: 2025-11-22 + production_tested: multiple (zpg6/better-auth-cloudflare, zwily/example-react-router-cloudflare-d1-drizzle-better-auth, foxlau/react-router-v7-better-auth, matthewlynch/better-auth-react-router-cloudflare-d1) + package_version: 1.4.0 + token_savings: ~80% + errors_prevented: 13 + official_docs: https://better-auth.com + github: https://github.com/better-auth/better-auth + breaking_changes: v1.4.0 - ESM-only (Nov 2025), v1.3 - Multi-team table changes (July 2025), v2.0.0 - D1 adapter patterns (Drizzle/Kysely required) + keywords: + - better-auth + - authentication + - cloudflare-d1 + - drizzle-orm + - kysely + - self-hosted-auth + - typescript-auth + - clerk-alternative + - authjs-alternative + - social-auth + - oauth + - session-management + - jwt + - 2fa + - passkeys + - multi-tenant + - organizations + - rbac +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +--- + +# better-auth - D1 Adapter & Error Prevention Guide + +**Package**: better-auth@1.4.0 (Nov 22, 2025) +**Breaking Changes**: ESM-only (v1.4.0), Multi-team table changes (v1.3), D1 requires Drizzle/Kysely (no direct adapter) + +--- + +## ⚠️ CRITICAL: D1 Adapter Requirement + +better-auth **DOES NOT** have `d1Adapter()`. You **MUST** use: +- **Drizzle ORM** (recommended): `drizzleAdapter(db, { provider: "sqlite" })` +- **Kysely**: `new Kysely({ dialect: new D1Dialect({ database: env.DB }) })` + +See Issue #1 below for details. + +--- + +## What's New in v1.4.0 (Nov 22, 2025) + +**Major Features:** +- **Stateless session management** - Sessions without database storage +- **ESM-only package** ⚠️ Breaking: CommonJS no longer supported +- **JWT key rotation** - Automatic key rotation for enhanced security +- **SCIM provisioning** - Enterprise user provisioning protocol +- **@standard-schema/spec** - Replaces ZodType for validation +- **CaptchaFox integration** - Built-in CAPTCHA support +- Automatic server-side IP detection +- Cookie-based account data storage +- Multiple passkey origins support +- RP-Initiated Logout endpoint (OIDC) + +📚 **Docs**: https://www.better-auth.com/changelogs + +--- + +## What's New in v1.3 (July 2025) + +**Major Features:** +- **SSO with SAML 2.0** - Enterprise single sign-on (moved to separate `@better-auth/sso` package) +- **Multi-team support** ⚠️ Breaking: `teamId` removed from member table, new `teamMembers` table required +- **Additional fields** - Custom fields for organization/member/invitation models +- Performance improvements and bug fixes + +📚 **Docs**: https://www.better-auth.com/blog/1-3 + +--- + +## Alternative: Kysely Adapter Pattern + +If you prefer Kysely over Drizzle: + +**File**: `src/auth.ts` + +```typescript +import { betterAuth } from "better-auth"; +import { Kysely, CamelCasePlugin } from "kysely"; +import { D1Dialect } from "kysely-d1"; + +type Env = { + DB: D1Database; + BETTER_AUTH_SECRET: string; + // ... other env vars +}; + +export function createAuth(env: Env) { + return betterAuth({ + secret: env.BETTER_AUTH_SECRET, + + // Kysely with D1Dialect + database: { + db: new Kysely({ + dialect: new D1Dialect({ + database: env.DB, + }), + plugins: [ + // CRITICAL: Required if using Drizzle schema with snake_case + new CamelCasePlugin(), + ], + }), + type: "sqlite", + }, + + emailAndPassword: { + enabled: true, + }, + + // ... other config + }); +} +``` + +**Why CamelCasePlugin?** + +If your Drizzle schema uses `snake_case` column names (e.g., `email_verified`), but better-auth expects `camelCase` (e.g., `emailVerified`), the `CamelCasePlugin` automatically converts between the two. + +--- + +## Framework Integrations + +### TanStack Start + +**⚠️ CRITICAL**: TanStack Start requires the `reactStartCookies` plugin to handle cookie setting properly. + +```typescript +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { reactStartCookies } from "better-auth/react-start"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { provider: "sqlite" }), + plugins: [ + twoFactor(), + organization(), + reactStartCookies(), // ⚠️ MUST be LAST plugin + ], +}); +``` + +**Why it's needed**: TanStack Start uses a special cookie handling system. Without this plugin, auth functions like `signInEmail()` and `signUpEmail()` won't set cookies properly, causing authentication to fail. + +**Important**: The `reactStartCookies` plugin **must be the last plugin in the array**. + +**API Route Setup** (`/src/routes/api/auth/$.ts`): +```typescript +import { auth } from '@/lib/auth' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/api/auth/$')({ + server: { + handlers: { + GET: ({ request }) => auth.handler(request), + POST: ({ request }) => auth.handler(request), + }, + }, +}) +``` + +📚 **Official Docs**: https://www.better-auth.com/docs/integrations/tanstack + +--- + +## Available Plugins (v1.3+) + +Better Auth provides plugins for advanced authentication features: + +| Plugin | Import | Description | Docs | +|--------|--------|-------------|------| +| **OIDC Provider** | `better-auth/plugins` | Build your own OpenID Connect provider (become an OAuth provider for other apps) | [📚](https://www.better-auth.com/docs/plugins/oidc-provider) | +| **SSO** | `better-auth/plugins` | Enterprise Single Sign-On with OIDC, OAuth2, and SAML 2.0 support | [📚](https://www.better-auth.com/docs/plugins/sso) | +| **Stripe** | `better-auth/plugins` | Payment and subscription management (stable as of v1.3+) | [📚](https://www.better-auth.com/docs/plugins/stripe) | +| **MCP** | `better-auth/plugins` | Act as OAuth provider for Model Context Protocol (MCP) clients | [📚](https://www.better-auth.com/docs/plugins/mcp) | +| **Expo** | `better-auth/expo` | React Native/Expo integration with secure cookie management | [📚](https://www.better-auth.com/docs/integrations/expo) | + +--- + +## API Reference + +### Overview: What You Get For Free + +When you call `auth.handler()`, better-auth automatically exposes **80+ production-ready REST endpoints** at `/api/auth/*`. Every endpoint is also available as a **server-side method** via `auth.api.*` for programmatic use. + +This dual-layer API system means: +- **Clients** (React, Vue, mobile apps) call HTTP endpoints directly +- **Server-side code** (middleware, background jobs) uses `auth.api.*` methods +- **Zero boilerplate** - no need to write auth endpoints manually + +**Time savings**: Building this from scratch = ~220 hours. With better-auth = ~4-8 hours. **97% reduction.** + +--- + +### Auto-Generated HTTP Endpoints + +All endpoints are automatically exposed at `/api/auth/*` when using `auth.handler()`. + +#### Core Authentication Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/sign-up/email` | POST | Register with email/password | +| `/sign-in/email` | POST | Authenticate with email/password | +| `/sign-out` | POST | Logout user | +| `/change-password` | POST | Update password (requires current password) | +| `/forget-password` | POST | Initiate password reset flow | +| `/reset-password` | POST | Complete password reset with token | +| `/send-verification-email` | POST | Send email verification link | +| `/verify-email` | GET | Verify email with token (`?token=`) | +| `/get-session` | GET | Retrieve current session | +| `/list-sessions` | GET | Get all active user sessions | +| `/revoke-session` | POST | End specific session | +| `/revoke-other-sessions` | POST | End all sessions except current | +| `/revoke-sessions` | POST | End all user sessions | +| `/update-user` | POST | Modify user profile (name, image) | +| `/change-email` | POST | Update email address | +| `/set-password` | POST | Add password to OAuth-only account | +| `/delete-user` | POST | Remove user account | +| `/list-accounts` | GET | Get linked authentication providers | +| `/link-social` | POST | Connect OAuth provider to account | +| `/unlink-account` | POST | Disconnect provider | + +#### Social OAuth Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/sign-in/social` | POST | Initiate OAuth flow (provider specified in body) | +| `/callback/:provider` | GET | OAuth callback handler (e.g., `/callback/google`) | +| `/get-access-token` | GET | Retrieve provider access token | + +**Example OAuth flow**: +```typescript +// Client initiates +await authClient.signIn.social({ + provider: "google", + callbackURL: "/dashboard", +}); + +// better-auth handles redirect to Google +// Google redirects back to /api/auth/callback/google +// better-auth creates session automatically +``` + +--- + +#### Plugin Endpoints + +##### Two-Factor Authentication (2FA Plugin) + +```typescript +import { twoFactor } from "better-auth/plugins"; +``` + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/two-factor/enable` | POST | Activate 2FA for user | +| `/two-factor/disable` | POST | Deactivate 2FA | +| `/two-factor/get-totp-uri` | GET | Get QR code URI for authenticator app | +| `/two-factor/verify-totp` | POST | Validate TOTP code from authenticator | +| `/two-factor/send-otp` | POST | Send OTP via email | +| `/two-factor/verify-otp` | POST | Validate email OTP | +| `/two-factor/generate-backup-codes` | POST | Create recovery codes | +| `/two-factor/verify-backup-code` | POST | Use backup code for login | +| `/two-factor/view-backup-codes` | GET | View current backup codes | + +📚 **Docs**: https://www.better-auth.com/docs/plugins/2fa + +##### Organization Plugin (Multi-Tenant SaaS) + +```typescript +import { organization } from "better-auth/plugins"; +``` + +**Organizations** (10 endpoints): + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/organization/create` | POST | Create organization | +| `/organization/list` | GET | List user's organizations | +| `/organization/get-full` | GET | Get complete org details | +| `/organization/update` | PUT | Modify organization | +| `/organization/delete` | DELETE | Remove organization | +| `/organization/check-slug` | GET | Verify slug availability | +| `/organization/set-active` | POST | Set active organization context | + +**Members** (8 endpoints): + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/organization/list-members` | GET | Get organization members | +| `/organization/add-member` | POST | Add member directly | +| `/organization/remove-member` | DELETE | Remove member | +| `/organization/update-member-role` | PUT | Change member role | +| `/organization/get-active-member` | GET | Get current member info | +| `/organization/leave` | POST | Leave organization | + +**Invitations** (7 endpoints): + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/organization/invite-member` | POST | Send invitation email | +| `/organization/accept-invitation` | POST | Accept invite | +| `/organization/reject-invitation` | POST | Reject invite | +| `/organization/cancel-invitation` | POST | Cancel pending invite | +| `/organization/get-invitation` | GET | Get invitation details | +| `/organization/list-invitations` | GET | List org invitations | +| `/organization/list-user-invitations` | GET | List user's pending invites | + +**Teams** (8 endpoints): + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/organization/create-team` | POST | Create team within org | +| `/organization/list-teams` | GET | List organization teams | +| `/organization/update-team` | PUT | Modify team | +| `/organization/remove-team` | DELETE | Remove team | +| `/organization/set-active-team` | POST | Set active team context | +| `/organization/list-team-members` | GET | List team members | +| `/organization/add-team-member` | POST | Add member to team | +| `/organization/remove-team-member` | DELETE | Remove team member | + +**Permissions & Roles** (6 endpoints): + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/organization/has-permission` | POST | Check if user has permission | +| `/organization/create-role` | POST | Create custom role | +| `/organization/delete-role` | DELETE | Delete custom role | +| `/organization/list-roles` | GET | List all roles | +| `/organization/get-role` | GET | Get role details | +| `/organization/update-role` | PUT | Modify role permissions | + +📚 **Docs**: https://www.better-auth.com/docs/plugins/organization + +##### Admin Plugin + +```typescript +import { admin } from "better-auth/plugins"; +``` + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/admin/create-user` | POST | Create user as admin | +| `/admin/list-users` | GET | List all users (with filters/pagination) | +| `/admin/set-role` | POST | Assign user role | +| `/admin/set-user-password` | POST | Change user password | +| `/admin/update-user` | PUT | Modify user details | +| `/admin/remove-user` | DELETE | Delete user account | +| `/admin/ban-user` | POST | Ban user account | +| `/admin/unban-user` | POST | Unban user | +| `/admin/list-user-sessions` | GET | Get user's active sessions | +| `/admin/revoke-user-session` | DELETE | End specific user session | +| `/admin/revoke-user-sessions` | DELETE | End all user sessions | +| `/admin/impersonate-user` | POST | Start impersonating user | +| `/admin/stop-impersonating` | POST | End impersonation session | + +📚 **Docs**: https://www.better-auth.com/docs/plugins/admin + +##### Other Plugin Endpoints + +**Passkey Plugin** (5 endpoints) - [Docs](https://www.better-auth.com/docs/plugins/passkey): +- `/passkey/add`, `/sign-in/passkey`, `/passkey/list`, `/passkey/delete`, `/passkey/update` + +**Magic Link Plugin** (2 endpoints) - [Docs](https://www.better-auth.com/docs/plugins/magic-link): +- `/sign-in/magic-link`, `/magic-link/verify` + +**Username Plugin** (2 endpoints) - [Docs](https://www.better-auth.com/docs/plugins/username): +- `/sign-in/username`, `/username/is-available` + +**Phone Number Plugin** (5 endpoints) - [Docs](https://www.better-auth.com/docs/plugins/phone-number): +- `/sign-in/phone-number`, `/phone-number/send-otp`, `/phone-number/verify`, `/phone-number/request-password-reset`, `/phone-number/reset-password` + +**Email OTP Plugin** (6 endpoints) - [Docs](https://www.better-auth.com/docs/plugins/email-otp): +- `/email-otp/send-verification-otp`, `/email-otp/check-verification-otp`, `/sign-in/email-otp`, `/email-otp/verify-email`, `/forget-password/email-otp`, `/email-otp/reset-password` + +**Anonymous Plugin** (1 endpoint) - [Docs](https://www.better-auth.com/docs/plugins/anonymous): +- `/sign-in/anonymous` + +**JWT Plugin** (2 endpoints) - [Docs](https://www.better-auth.com/docs/plugins/jwt): +- `/token` (get JWT), `/jwks` (public key for verification) + +**OpenAPI Plugin** (2 endpoints) - [Docs](https://www.better-auth.com/docs/plugins/open-api): +- `/reference` (interactive API docs with Scalar UI) +- `/generate-openapi-schema` (get OpenAPI spec as JSON) + +--- + +### Server-Side API Methods (`auth.api.*`) + +Every HTTP endpoint has a corresponding server-side method. Use these for: +- **Server-side middleware** (protecting routes) +- **Background jobs** (user cleanup, notifications) +- **Admin operations** (bulk user management) +- **Custom auth flows** (programmatic session creation) + +#### Core API Methods + +```typescript +// Authentication +await auth.api.signUpEmail({ + body: { email, password, name }, + headers: request.headers, +}); + +await auth.api.signInEmail({ + body: { email, password, rememberMe: true }, + headers: request.headers, +}); + +await auth.api.signOut({ headers: request.headers }); + +// Session Management +const session = await auth.api.getSession({ headers: request.headers }); + +await auth.api.listSessions({ headers: request.headers }); + +await auth.api.revokeSession({ + body: { token: "session_token_here" }, + headers: request.headers, +}); + +// User Management +await auth.api.updateUser({ + body: { name: "New Name", image: "https://..." }, + headers: request.headers, +}); + +await auth.api.changeEmail({ + body: { newEmail: "newemail@example.com" }, + headers: request.headers, +}); + +await auth.api.deleteUser({ + body: { password: "current_password" }, + headers: request.headers, +}); + +// Account Linking +await auth.api.linkSocialAccount({ + body: { provider: "google" }, + headers: request.headers, +}); + +await auth.api.unlinkAccount({ + body: { providerId: "google", accountId: "google_123" }, + headers: request.headers, +}); +``` + +#### Plugin API Methods + +**2FA Plugin**: +```typescript +// Enable 2FA +const { totpUri, backupCodes } = await auth.api.enableTwoFactor({ + body: { issuer: "MyApp" }, + headers: request.headers, +}); + +// Verify TOTP code +await auth.api.verifyTOTP({ + body: { code: "123456", trustDevice: true }, + headers: request.headers, +}); + +// Generate backup codes +const { backupCodes } = await auth.api.generateBackupCodes({ + headers: request.headers, +}); +``` + +**Organization Plugin**: +```typescript +// Create organization +const org = await auth.api.createOrganization({ + body: { name: "Acme Corp", slug: "acme" }, + headers: request.headers, +}); + +// Add member +await auth.api.addMember({ + body: { + userId: "user_123", + role: "admin", + organizationId: org.id, + }, + headers: request.headers, +}); + +// Check permissions +const hasPermission = await auth.api.hasPermission({ + body: { + organizationId: org.id, + permission: "users:delete", + }, + headers: request.headers, +}); +``` + +**Admin Plugin**: +```typescript +// List users with pagination +const users = await auth.api.listUsers({ + query: { + search: "john", + limit: 10, + offset: 0, + sortBy: "createdAt", + sortOrder: "desc", + }, + headers: request.headers, +}); + +// Ban user +await auth.api.banUser({ + body: { + userId: "user_123", + reason: "Violation of ToS", + expiresAt: new Date("2025-12-31"), + }, + headers: request.headers, +}); + +// Impersonate user (for admin support) +const impersonationSession = await auth.api.impersonateUser({ + body: { + userId: "user_123", + expiresIn: 3600, // 1 hour + }, + headers: request.headers, +}); +``` + +--- + +### When to Use Which + +| Use Case | Use HTTP Endpoints | Use `auth.api.*` Methods | +|----------|-------------------|--------------------------| +| **Client-side auth** | ✅ Yes | ❌ No | +| **Server middleware** | ❌ No | ✅ Yes | +| **Background jobs** | ❌ No | ✅ Yes | +| **Admin dashboards** | ✅ Yes (from client) | ✅ Yes (from server) | +| **Custom auth flows** | ❌ No | ✅ Yes | +| **Mobile apps** | ✅ Yes | ❌ No | +| **API routes** | ✅ Yes (proxy to handler) | ✅ Yes (direct calls) | + +**Example: Protected Route Middleware** + +```typescript +import { Hono } from "hono"; +import { createAuth } from "./auth"; +import { createDatabase } from "./db"; + +const app = new Hono<{ Bindings: Env }>(); + +// Middleware using server-side API +app.use("/api/protected/*", async (c, next) => { + const db = createDatabase(c.env.DB); + const auth = createAuth(db, c.env); + + // Use server-side method + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + // Attach to context + c.set("user", session.user); + c.set("session", session.session); + + await next(); +}); + +// Protected route +app.get("/api/protected/profile", async (c) => { + const user = c.get("user"); + return c.json({ user }); +}); +``` + +--- + +### Discovering Available Endpoints + +Use the **OpenAPI plugin** to see all endpoints in your configuration: + +```typescript +import { betterAuth } from "better-auth"; +import { openAPI } from "better-auth/plugins"; + +export const auth = betterAuth({ + database: /* ... */, + plugins: [ + openAPI(), // Adds /api/auth/reference endpoint + ], +}); +``` + +**Interactive documentation**: Visit `http://localhost:8787/api/auth/reference` + +This shows a **Scalar UI** with: +- ✅ All available endpoints grouped by feature +- ✅ Request/response schemas with types +- ✅ Try-it-out functionality (test endpoints in browser) +- ✅ Authentication requirements +- ✅ Code examples in multiple languages + +**Programmatic access**: +```typescript +const schema = await auth.api.generateOpenAPISchema(); +console.log(JSON.stringify(schema, null, 2)); +// Returns full OpenAPI 3.0 spec +``` + +--- + +### Quantified Time Savings + +**Building from scratch** (manual implementation): +- Core auth endpoints (sign-up, sign-in, OAuth, sessions): **40 hours** +- Email verification & password reset: **10 hours** +- 2FA system (TOTP, backup codes, email OTP): **20 hours** +- Organizations (teams, invitations, RBAC): **60 hours** +- Admin panel (user management, impersonation): **30 hours** +- Testing & debugging: **50 hours** +- Security hardening: **20 hours** + +**Total manual effort**: **~220 hours** (5.5 weeks full-time) + +**With better-auth**: +- Initial setup: **2-4 hours** +- Customization & styling: **2-4 hours** + +**Total with better-auth**: **4-8 hours** + +**Savings**: **~97% development time** + +--- + +### Key Takeaway + +better-auth provides **80+ production-ready endpoints** covering: +- ✅ Core authentication (20 endpoints) +- ✅ 2FA & passwordless (15 endpoints) +- ✅ Organizations & teams (35 endpoints) +- ✅ Admin & user management (15 endpoints) +- ✅ Social OAuth (auto-configured callbacks) +- ✅ OpenAPI documentation (interactive UI) + +**You write zero endpoint code.** Just configure features and call `auth.handler()`. + +--- + +## Known Issues & Solutions + +### Issue 1: "d1Adapter is not exported" Error + +**Problem**: Code shows `import { d1Adapter } from 'better-auth/adapters/d1'` but this doesn't exist. + +**Symptoms**: TypeScript error or runtime error about missing export. + +**Solution**: Use Drizzle or Kysely instead: + +```typescript +// ❌ WRONG - This doesn't exist +import { d1Adapter } from 'better-auth/adapters/d1' +database: d1Adapter(env.DB) + +// ✅ CORRECT - Use Drizzle +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { drizzle } from 'drizzle-orm/d1' +const db = drizzle(env.DB, { schema }) +database: drizzleAdapter(db, { provider: "sqlite" }) + +// ✅ CORRECT - Use Kysely +import { Kysely } from 'kysely' +import { D1Dialect } from 'kysely-d1' +database: { + db: new Kysely({ dialect: new D1Dialect({ database: env.DB }) }), + type: "sqlite" +} +``` + +**Source**: Verified from 4 production repositories using better-auth + D1 + +--- + +### Issue 2: Schema Generation Fails + +**Problem**: `npx better-auth migrate` doesn't create D1-compatible schema. + +**Symptoms**: Migration SQL has wrong syntax or doesn't work with D1. + +**Solution**: Use Drizzle Kit to generate migrations: + +```bash +# Generate migration from Drizzle schema +npx drizzle-kit generate + +# Apply to D1 +wrangler d1 migrations apply my-app-db --remote +``` + +**Why**: Drizzle Kit generates SQLite-compatible SQL that works with D1. + +--- + +### Issue 3: "CamelCase" vs "snake_case" Column Mismatch + +**Problem**: Database has `email_verified` but better-auth expects `emailVerified`. + +**Symptoms**: Session reads fail, user data missing fields. + +**Solution**: Use `CamelCasePlugin` with Kysely or configure Drizzle properly: + +**With Kysely**: +```typescript +import { CamelCasePlugin } from "kysely"; + +new Kysely({ + dialect: new D1Dialect({ database: env.DB }), + plugins: [new CamelCasePlugin()], // Converts between naming conventions +}) +``` + +**With Drizzle**: Define schema with camelCase from the start (as shown in examples). + +--- + +### Issue 4: D1 Eventual Consistency + +**Problem**: Session reads immediately after write return stale data. + +**Symptoms**: User logs in but `getSession()` returns null on next request. + +**Solution**: Use Cloudflare KV for session storage (strong consistency): + +```typescript +import { betterAuth } from "better-auth"; + +export function createAuth(db: Database, env: Env) { + return betterAuth({ + database: drizzleAdapter(db, { provider: "sqlite" }), + session: { + storage: { + get: async (sessionId) => { + const session = await env.SESSIONS_KV.get(sessionId); + return session ? JSON.parse(session) : null; + }, + set: async (sessionId, session, ttl) => { + await env.SESSIONS_KV.put(sessionId, JSON.stringify(session), { + expirationTtl: ttl, + }); + }, + delete: async (sessionId) => { + await env.SESSIONS_KV.delete(sessionId); + }, + }, + }, + }); +} +``` + +**Add to `wrangler.toml`**: +```toml +[[kv_namespaces]] +binding = "SESSIONS_KV" +id = "your-kv-namespace-id" +``` + +--- + +### Issue 5: CORS Errors for SPA Applications + +**Problem**: CORS errors when auth API is on different origin than frontend. + +**Symptoms**: `Access-Control-Allow-Origin` errors in browser console. + +**Solution**: Configure CORS headers in Worker: + +```typescript +import { cors } from "hono/cors"; + +app.use( + "/api/auth/*", + cors({ + origin: ["https://yourdomain.com", "http://localhost:3000"], + credentials: true, // Allow cookies + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + }) +); +``` + +--- + +### Issue 6: OAuth Redirect URI Mismatch + +**Problem**: Social sign-in fails with "redirect_uri_mismatch" error. + +**Symptoms**: Google/GitHub OAuth returns error after user consent. + +**Solution**: Ensure exact match in OAuth provider settings: + +``` +Provider setting: https://yourdomain.com/api/auth/callback/google +better-auth URL: https://yourdomain.com/api/auth/callback/google + +❌ Wrong: http vs https, trailing slash, subdomain mismatch +✅ Right: Exact character-for-character match +``` + +**Check better-auth callback URL**: +```typescript +// It's always: {baseURL}/api/auth/callback/{provider} +const callbackURL = `${env.BETTER_AUTH_URL}/api/auth/callback/google`; +console.log("Configure this URL in Google Console:", callbackURL); +``` + +--- + +### Issue 7: Missing Dependencies + +**Problem**: TypeScript errors or runtime errors about missing packages. + +**Symptoms**: `Cannot find module 'drizzle-orm'` or similar. + +**Solution**: Install all required packages: + +**For Drizzle approach**: +```bash +npm install better-auth drizzle-orm drizzle-kit @cloudflare/workers-types +``` + +**For Kysely approach**: +```bash +npm install better-auth kysely kysely-d1 @cloudflare/workers-types +``` + +--- + +### Issue 8: Email Verification Not Sending + +**Problem**: Email verification links never arrive. + +**Symptoms**: User signs up, but no email received. + +**Solution**: Implement `sendVerificationEmail` handler: + +```typescript +export const auth = betterAuth({ + database: /* ... */, + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + }, + emailVerification: { + sendVerificationEmail: async ({ user, url }) => { + // Use your email service (SendGrid, Resend, etc.) + await sendEmail({ + to: user.email, + subject: "Verify your email", + html: ` +

Click the link below to verify your email:

+ Verify Email + `, + }); + }, + sendOnSignUp: true, + autoSignInAfterVerification: true, + expiresIn: 3600, // 1 hour + }, +}); +``` + +**For Cloudflare**: Use Cloudflare Email Routing or external service (Resend, SendGrid). + +--- + +### Issue 9: Session Expires Too Quickly + +**Problem**: Session expires unexpectedly or never expires. + +**Symptoms**: User logged out unexpectedly or session persists after logout. + +**Solution**: Configure session expiration: + +```typescript +export const auth = betterAuth({ + database: /* ... */, + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days (in seconds) + updateAge: 60 * 60 * 24, // Update session every 24 hours + }, +}); +``` + +--- + +### Issue 10: Social Provider Missing User Data + +**Problem**: Social sign-in succeeds but missing user data (name, avatar). + +**Symptoms**: `session.user.name` is null after Google/GitHub sign-in. + +**Solution**: Request additional scopes: + +```typescript +socialProviders: { + google: { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + scope: ["openid", "email", "profile"], // Include 'profile' for name/image + }, + github: { + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + scope: ["user:email", "read:user"], // 'read:user' for full profile + }, +} +``` + +--- + +### Issue 11: TypeScript Errors with Drizzle Schema + +**Problem**: TypeScript complains about schema types. + +**Symptoms**: `Type 'DrizzleD1Database' is not assignable to...` + +**Solution**: Export proper types from database: + +```typescript +// src/db/index.ts +import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1"; +import * as schema from "./schema"; + +export type Database = DrizzleD1Database; + +export function createDatabase(d1: D1Database): Database { + return drizzle(d1, { schema }); +} +``` + +--- + +### Issue 12: Wrangler Dev Mode Not Working + +**Problem**: `wrangler dev` fails with database errors. + +**Symptoms**: "Database not found" or migration errors in local dev. + +**Solution**: Apply migrations locally first: + +```bash +# Apply migrations to local D1 +wrangler d1 migrations apply my-app-db --local + +# Then run dev server +wrangler dev +``` + +--- + +### Issue 13: User Data Updates Not Reflecting in UI (with TanStack Query) + +**Problem**: After updating user data (e.g., avatar, name), changes don't appear in `useSession()` despite calling `queryClient.invalidateQueries()`. + +**Symptoms**: Avatar image or user profile data appears stale after successful update. TanStack Query cache shows updated data, but better-auth session still shows old values. + +**Root Cause**: better-auth uses **nanostores** for session state management, not TanStack Query. Calling `queryClient.invalidateQueries()` only invalidates React Query cache, not the better-auth nanostore. + +**Solution**: Manually notify the nanostore after updating user data: + +```typescript +// Update user data +const { data, error } = await authClient.updateUser({ + image: newAvatarUrl, + name: newName +}) + +if (!error) { + // Manually invalidate better-auth session state + authClient.$store.notify('$sessionSignal') + + // Optional: Also invalidate React Query if using it for other data + queryClient.invalidateQueries({ queryKey: ['user-profile'] }) +} +``` + +**When to use**: +- Using better-auth + TanStack Query together +- Updating user profile fields (name, image, email) +- Any operation that modifies session user data client-side + +**Alternative**: Call `refetch()` from `useSession()`, but `$store.notify()` is more direct: + +```typescript +const { data: session, refetch } = authClient.useSession() +// After update +await refetch() +``` + +**Note**: `$store` is an undocumented internal API. This pattern is production-validated but may change in future better-auth versions. + +**Source**: Community-discovered pattern, production use verified + +--- + +## Migration Guides + +### From Clerk + +**Key differences**: +- Clerk: Third-party service → better-auth: Self-hosted +- Clerk: Proprietary → better-auth: Open source +- Clerk: Monthly cost → better-auth: Free + +**Migration steps**: + +1. **Export user data** from Clerk (CSV or API) +2. **Import into better-auth database**: + ```typescript + // migration script + const clerkUsers = await fetchClerkUsers(); + + for (const clerkUser of clerkUsers) { + await db.insert(user).values({ + id: clerkUser.id, + email: clerkUser.email, + emailVerified: clerkUser.email_verified, + name: clerkUser.first_name + " " + clerkUser.last_name, + image: clerkUser.profile_image_url, + }); + } + ``` +3. **Replace Clerk SDK** with better-auth client: + ```typescript + // Before (Clerk) + import { useUser } from "@clerk/nextjs"; + const { user } = useUser(); + + // After (better-auth) + import { authClient } from "@/lib/auth-client"; + const { data: session } = authClient.useSession(); + const user = session?.user; + ``` +4. **Update middleware** for session verification +5. **Configure social providers** (same OAuth apps, different config) + +--- + +### From Auth.js (NextAuth) + +**Key differences**: +- Auth.js: Limited features → better-auth: Comprehensive (2FA, orgs, etc.) +- Auth.js: Callbacks-heavy → better-auth: Plugin-based +- Auth.js: Session handling varies → better-auth: Consistent + +**Migration steps**: + +1. **Database schema**: Auth.js and better-auth use similar schemas, but column names differ +2. **Replace configuration**: + ```typescript + // Before (Auth.js) + import NextAuth from "next-auth"; + import GoogleProvider from "next-auth/providers/google"; + + export default NextAuth({ + providers: [GoogleProvider({ /* ... */ })], + }); + + // After (better-auth) + import { betterAuth } from "better-auth"; + + export const auth = betterAuth({ + socialProviders: { + google: { /* ... */ }, + }, + }); + ``` +3. **Update client hooks**: + ```typescript + // Before + import { useSession } from "next-auth/react"; + + // After + import { authClient } from "@/lib/auth-client"; + const { data: session } = authClient.useSession(); + ``` + +--- + +## Additional Resources + +### Official Documentation + +- **Homepage**: https://better-auth.com +- **Introduction**: https://www.better-auth.com/docs/introduction +- **Installation**: https://www.better-auth.com/docs/installation +- **Basic Usage**: https://www.better-auth.com/docs/basic-usage + +### Core Concepts + +- **Session Management**: https://www.better-auth.com/docs/concepts/session-management +- **Users & Accounts**: https://www.better-auth.com/docs/concepts/users-accounts +- **Client SDK**: https://www.better-auth.com/docs/concepts/client +- **Plugins System**: https://www.better-auth.com/docs/concepts/plugins + +### Authentication Methods + +- **Email & Password**: https://www.better-auth.com/docs/authentication/email-password +- **OAuth Providers**: https://www.better-auth.com/docs/concepts/oauth + +### Plugin Documentation + +**Core Plugins**: +- **2FA (Two-Factor)**: https://www.better-auth.com/docs/plugins/2fa +- **Organization**: https://www.better-auth.com/docs/plugins/organization +- **Admin**: https://www.better-auth.com/docs/plugins/admin +- **Multi-Session**: https://www.better-auth.com/docs/plugins/multi-session +- **API Key**: https://www.better-auth.com/docs/plugins/api-key +- **Generic OAuth**: https://www.better-auth.com/docs/plugins/generic-oauth + +**Passwordless Plugins**: +- **Passkey**: https://www.better-auth.com/docs/plugins/passkey +- **Magic Link**: https://www.better-auth.com/docs/plugins/magic-link +- **Email OTP**: https://www.better-auth.com/docs/plugins/email-otp +- **Phone Number**: https://www.better-auth.com/docs/plugins/phone-number +- **Anonymous**: https://www.better-auth.com/docs/plugins/anonymous + +**Advanced Plugins**: +- **Username**: https://www.better-auth.com/docs/plugins/username +- **JWT**: https://www.better-auth.com/docs/plugins/jwt +- **OpenAPI**: https://www.better-auth.com/docs/plugins/open-api +- **OIDC Provider**: https://www.better-auth.com/docs/plugins/oidc-provider +- **SSO**: https://www.better-auth.com/docs/plugins/sso +- **Stripe**: https://www.better-auth.com/docs/plugins/stripe +- **MCP**: https://www.better-auth.com/docs/plugins/mcp + +### Framework Integrations + +- **TanStack Start**: https://www.better-auth.com/docs/integrations/tanstack +- **Expo (React Native)**: https://www.better-auth.com/docs/integrations/expo + +### Community & Support + +- **GitHub**: https://github.com/better-auth/better-auth (22.4k ⭐) +- **Examples**: https://github.com/better-auth/better-auth/tree/main/examples +- **Discord**: https://discord.gg/better-auth +- **Changelog**: https://github.com/better-auth/better-auth/releases + +### Related Documentation + +- **Drizzle ORM**: https://orm.drizzle.team/docs/get-started-sqlite +- **Kysely**: https://kysely.dev/ + +--- + +## Production Examples + +**Verified working D1 repositories** (all use Drizzle or Kysely): + +1. **zpg6/better-auth-cloudflare** - Drizzle + D1 (includes CLI) +2. **zwily/example-react-router-cloudflare-d1-drizzle-better-auth** - Drizzle + D1 +3. **foxlau/react-router-v7-better-auth** - Drizzle + D1 +4. **matthewlynch/better-auth-react-router-cloudflare-d1** - Kysely + D1 + +**None** use a direct `d1Adapter` - all require Drizzle/Kysely. + +--- + +## Version Compatibility + +**Tested with**: +- `better-auth@1.3.34` +- `drizzle-orm@0.44.7` +- `drizzle-kit@0.31.6` +- `kysely@0.28.8` +- `kysely-d1@0.4.0` +- `@cloudflare/workers-types@latest` +- `hono@4.0.0` +- Node.js 18+, Bun 1.0+ + +**Breaking changes**: Check changelog when upgrading: https://github.com/better-auth/better-auth/releases + +--- + +**Token Efficiency**: +- **Without skill**: ~28,000 tokens (D1 adapter errors, TanStack Start cookies, nanostore invalidation, OAuth flows, API discovery) +- **With skill**: ~5,600 tokens (focused on errors + breaking changes + API reference) +- **Savings**: ~80% (~22,400 tokens) + +**Errors prevented**: 13 documented issues with exact solutions +**Key value**: D1 adapter requirement, v1.4.0/v1.3 breaking changes, TanStack Start fix, nanostore pattern, 80+ endpoint reference + +--- + +**Last verified**: 2025-11-22 | **Skill version**: 3.0.0 | **Changes**: Added v1.4.0 (ESM-only, stateless sessions, SCIM) and v1.3 (SSO/SAML, multi-team) knowledge gaps. Removed tutorial/setup (~700 lines). Focused on error prevention + breaking changes + API reference. diff --git a/assets/auth-flow-diagram.md b/assets/auth-flow-diagram.md new file mode 100644 index 0000000..fdafed9 --- /dev/null +++ b/assets/auth-flow-diagram.md @@ -0,0 +1,370 @@ +# better-auth Authentication Flow Diagrams + +Visual representations of common authentication flows using better-auth. + +--- + +## 1. Email/Password Sign-Up Flow + +``` +┌─────────┐ ┌─────────┐ ┌──────────┐ +│ Client │ │ Worker │ │ D1 │ +└────┬────┘ └────┬────┘ └────┬─────┘ + │ │ │ + │ POST /api/auth/signup │ │ + │ { email, password } │ │ + ├──────────────────────────>│ │ + │ │ Hash password (bcrypt) │ + │ │ │ + │ │ INSERT INTO users │ + │ ├──────────────────────────>│ + │ │ │ + │ │ Generate verification │ + │ │ token │ + │ │ │ + │ │ INSERT INTO │ + │ │ verification_tokens │ + │ ├──────────────────────────>│ + │ │ │ + │ │ Send verification email │ + │ │ (via email service) │ + │ │ │ + │ { success: true } │ │ + │<──────────────────────────┤ │ + │ │ │ + │ │ │ + │ User clicks email link │ │ + │ │ │ + │ GET /api/auth/verify? │ │ + │ token=xyz │ │ + ├──────────────────────────>│ │ + │ │ Verify token │ + │ ├──────────────────────────>│ + │ │ │ + │ │ UPDATE users SET │ + │ │ email_verified = true │ + │ ├──────────────────────────>│ + │ │ │ + │ Redirect to dashboard │ │ + │<──────────────────────────┤ │ + │ │ │ +``` + +--- + +## 2. Social Sign-In Flow (Google OAuth) + +``` +┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ +│ Client │ │ Worker │ │ D1 │ │ Google │ +└────┬────┘ └────┬────┘ └────┬─────┘ └────┬────┘ + │ │ │ │ + │ Click "Sign │ │ │ + │ in with │ │ │ + │ Google" │ │ │ + │ │ │ │ + │ POST /api/ │ │ │ + │ auth/signin/ │ │ │ + │ google │ │ │ + ├────────────────>│ │ │ + │ │ Generate OAuth │ │ + │ │ state + PKCE │ │ + │ │ │ │ + │ Redirect to │ │ │ + │ Google OAuth │ │ │ + │<────────────────┤ │ │ + │ │ │ │ + │ │ │ │ + │ User authorizes on Google │ │ + ├───────────────────────────────────────────────────────>│ + │ │ │ │ + │ │ │ User approves │ + │<───────────────────────────────────────────────────────┤ + │ │ │ │ + │ Redirect to │ │ │ + │ callback with │ │ │ + │ code │ │ │ + │ │ │ │ + │ GET /api/auth/ │ │ │ + │ callback/ │ │ │ + │ google?code= │ │ │ + ├────────────────>│ │ │ + │ │ Exchange code │ │ + │ │ for tokens │ │ + │ ├─────────────────────────────────────>│ + │ │ │ │ + │ │ { access_token, │ │ + │ │ id_token } │ │ + │ │<─────────────────────────────────────┤ + │ │ │ │ + │ │ Fetch user info │ │ + │ ├─────────────────────────────────────>│ + │ │ │ │ + │ │ { email, name, │ │ + │ │ picture } │ │ + │ │<─────────────────────────────────────┤ + │ │ │ │ + │ │ Find or create │ │ + │ │ user │ │ + │ ├─────────────────>│ │ + │ │ │ │ + │ │ Store account │ │ + │ │ (provider data) │ │ + │ ├─────────────────>│ │ + │ │ │ │ + │ │ Create session │ │ + │ ├─────────────────>│ │ + │ │ │ │ + │ Set session │ │ │ + │ cookie + │ │ │ + │ redirect │ │ │ + │<────────────────┤ │ │ + │ │ │ │ +``` + +--- + +## 3. Session Verification Flow + +``` +┌─────────┐ ┌─────────┐ ┌──────────┐ +│ Client │ │ Worker │ │ KV │ +└────┬────┘ └────┬────┘ └────┬─────┘ + │ │ │ + │ GET /api/protected │ │ + │ Cookie: session=xyz │ │ + ├───────────────────────>│ │ + │ │ Extract session ID │ + │ │ from cookie │ + │ │ │ + │ │ GET session from KV │ + │ ├───────────────────────>│ + │ │ │ + │ │ { userId, expiresAt } │ + │ │<───────────────────────┤ + │ │ │ + │ │ Check expiration │ + │ │ │ + │ If valid: │ │ + │ { data: ... } │ │ + │<───────────────────────┤ │ + │ │ │ + │ If invalid: │ │ + │ 401 Unauthorized │ │ + │<───────────────────────┤ │ + │ │ │ +``` + +--- + +## 4. Password Reset Flow + +``` +┌─────────┐ ┌─────────┐ ┌──────────┐ +│ Client │ │ Worker │ │ D1 │ +└────┬────┘ └────┬────┘ └────┬─────┘ + │ │ │ + │ POST /api/auth/ │ │ + │ forgot-password │ │ + │ { email } │ │ + ├───────────────────────>│ │ + │ │ Find user by email │ + │ ├───────────────────────>│ + │ │ │ + │ │ Generate reset token │ + │ │ │ + │ │ INSERT INTO │ + │ │ verification_tokens │ + │ ├───────────────────────>│ + │ │ │ + │ │ Send reset email │ + │ │ │ + │ { success: true } │ │ + │<───────────────────────┤ │ + │ │ │ + │ │ │ + │ User clicks email │ │ + │ link │ │ + │ │ │ + │ GET /reset-password? │ │ + │ token=xyz │ │ + ├───────────────────────>│ │ + │ │ Verify token │ + │ ├───────────────────────>│ + │ │ │ + │ Show reset form │ │ + │<───────────────────────┤ │ + │ │ │ + │ POST /api/auth/ │ │ + │ reset-password │ │ + │ { token, password } │ │ + ├───────────────────────>│ │ + │ │ Hash new password │ + │ │ │ + │ │ UPDATE users │ + │ ├───────────────────────>│ + │ │ │ + │ │ DELETE token │ + │ ├───────────────────────>│ + │ │ │ + │ Redirect to login │ │ + │<───────────────────────┤ │ + │ │ │ +``` + +--- + +## 5. Two-Factor Authentication (2FA) Flow + +``` +┌─────────┐ ┌─────────┐ ┌──────────┐ +│ Client │ │ Worker │ │ D1 │ +└────┬────┘ └────┬────┘ └────┬─────┘ + │ │ │ + │ POST /api/auth/ │ │ + │ signin │ │ + │ { email, password } │ │ + ├───────────────────────>│ │ + │ │ Verify credentials │ + │ ├───────────────────────>│ + │ │ │ + │ │ Check if 2FA enabled │ + │ ├───────────────────────>│ + │ │ │ + │ { requires2FA: true } │ │ + │<───────────────────────┤ │ + │ │ │ + │ Show 2FA input │ │ + │ │ │ + │ POST /api/auth/ │ │ + │ verify-2fa │ │ + │ { code: "123456" } │ │ + ├───────────────────────>│ │ + │ │ Get 2FA secret │ + │ ├───────────────────────>│ + │ │ │ + │ │ Verify TOTP code │ + │ │ │ + │ If valid: │ │ + │ Create session │ │ + │ + redirect │ │ + │<───────────────────────┤ │ + │ │ │ +``` + +--- + +## 6. Organization/Team Flow + +``` +┌─────────┐ ┌─────────┐ ┌──────────┐ +│ Client │ │ Worker │ │ D1 │ +└────┬────┘ └────┬────┘ └────┬─────┘ + │ │ │ + │ POST /api/org/create │ │ + │ { name, slug } │ │ + ├───────────────────────>│ │ + │ │ Verify session │ + │ │ │ + │ │ INSERT INTO orgs │ + │ ├───────────────────────>│ + │ │ │ + │ │ INSERT INTO │ + │ │ org_members │ + │ │ (user as owner) │ + │ ├───────────────────────>│ + │ │ │ + │ { org: { ... } } │ │ + │<───────────────────────┤ │ + │ │ │ + │ │ │ + │ POST /api/org/invite │ │ + │ { orgId, email, │ │ + │ role } │ │ + ├───────────────────────>│ │ + │ │ Check permissions │ + │ ├───────────────────────>│ + │ │ │ + │ │ Generate invite token │ + │ │ │ + │ │ INSERT INTO │ + │ │ org_invitations │ + │ ├───────────────────────>│ + │ │ │ + │ │ Send invite email │ + │ │ │ + │ { success: true } │ │ + │<───────────────────────┤ │ + │ │ │ +``` + +--- + +## Database Schema Overview + +``` +┌──────────────────────┐ +│ users │ +├──────────────────────┤ +│ id (PK) │ +│ email (UNIQUE) │ +│ email_verified │ +│ name │ +│ image │ +│ role │ +│ created_at │ +│ updated_at │ +└──────────┬───────────┘ + │ + │ 1:N + │ +┌──────────┴───────────┐ ┌──────────────────────┐ +│ sessions │ │ accounts │ +├──────────────────────┤ ├──────────────────────┤ +│ id (PK) │ │ id (PK) │ +│ user_id (FK) │◄───────┤ user_id (FK) │ +│ expires_at │ │ provider │ +│ ip_address │ │ provider_account_id │ +│ user_agent │ │ access_token │ +│ created_at │ │ refresh_token │ +└──────────────────────┘ │ expires_at │ + │ created_at │ + └──────────────────────┘ + +┌──────────────────────┐ +│ verification_tokens │ +├──────────────────────┤ +│ identifier │ +│ token │ +│ expires │ +│ created_at │ +└──────────────────────┘ + +┌──────────────────────┐ ┌──────────────────────┐ +│ organizations │ │ organization_members │ +├──────────────────────┤ ├──────────────────────┤ +│ id (PK) │ │ id (PK) │ +│ name │ │ organization_id (FK) │◄──┐ +│ slug (UNIQUE) │◄───────┤ user_id (FK) │ │ +│ logo │ │ role │ │ +│ created_at │ │ created_at │ │ +│ updated_at │ └──────────────────────┘ │ +└──────────────────────┘ │ + │ +┌──────────────────────┐ │ +│organization_invites │ │ +├──────────────────────┤ │ +│ id (PK) │ │ +│ organization_id (FK) │────────────────────────────────────┘ +│ email │ +│ role │ +│ invited_by (FK) │ +│ token │ +│ expires_at │ +│ created_at │ +└──────────────────────┘ +``` + +--- + +These diagrams illustrate the complete authentication flows supported by better-auth. Use them as reference when implementing auth in your application. diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..1ceba81 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,77 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:jezweb/claude-skills:skills/better-auth", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "24fa4e88b5217028acd3a4228d614d74979d6d8c", + "treeHash": "0108f663286dc7412e736f00d9187d1b66db191540fcae3d2103d68858800b75", + "generatedAt": "2025-11-28T10:19:03.307825Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "better-auth", + "description": "Build authentication systems for TypeScript/Cloudflare Workers with social auth, 2FA, passkeys, organizations, and RBAC. Self-hosted alternative to Clerk/Auth.js. IMPORTANT: Requires Drizzle ORM or Kysely for D1 - no direct D1 adapter. Use when: self-hosting auth on Cloudflare D1, migrating from Clerk, implementing multi-tenant SaaS, or troubleshooting D1 adapter errors, session serialization, OAuth flows.", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "5ec2bd2b40c398f91b05ade2579ed0c1ac0668bda3abd49c7ff53130d5308125" + }, + { + "path": "SKILL.md", + "sha256": "2c6cdcfdb09f17fdca05f47f7edc24c836d8518877bb96954883813bb3a7ec22" + }, + { + "path": "references/cloudflare-worker-drizzle.ts", + "sha256": "a4f6724b67cc0ce0569e21c20d5c59129b7d19955461ef1eed21b78173a89fda" + }, + { + "path": "references/cloudflare-worker-kysely.ts", + "sha256": "fdbc996a26f3c89f9267de12b189e744e72d30ba2f5547f2047441b06f0439da" + }, + { + "path": "references/database-schema.ts", + "sha256": "c0632e5b3fce888bec2a038b3b594bb4b99f7f8f4058ff6584c2a527e6d836a2" + }, + { + "path": "references/react-client-hooks.tsx", + "sha256": "017e3891bae23a114b2ad24cd1fa5596dfa4eb1cdb667fd201eaa00a713c3a2c" + }, + { + "path": "references/nextjs/postgres-example.ts", + "sha256": "200783bf2a3a892455f93748bca52bb36d9196dff2f1791c0946829e15ea5749" + }, + { + "path": "references/nextjs/README.md", + "sha256": "1d2d5290502b0fea9cdcff4e50afe5e7ae407a2ecdb9229b138ee527ae9114c0" + }, + { + "path": "scripts/setup-d1-drizzle.sh", + "sha256": "6a8ac2394c55ef59ae5df66229875a791fa7580d577c635ea289cd7eae0f7af5" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "eb7c8c3da4b78bb758a3bb092adbb63afa47fbd1a2fc058310f4401e03a0177b" + }, + { + "path": "assets/auth-flow-diagram.md", + "sha256": "8662944d9d3f49b9e83cb511b9234d51cbefcec45233d610e77784e8fac5f9fc" + } + ], + "dirSha256": "0108f663286dc7412e736f00d9187d1b66db191540fcae3d2103d68858800b75" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/references/cloudflare-worker-drizzle.ts b/references/cloudflare-worker-drizzle.ts new file mode 100644 index 0000000..dfef5a6 --- /dev/null +++ b/references/cloudflare-worker-drizzle.ts @@ -0,0 +1,334 @@ +/** + * Complete Cloudflare Worker with better-auth + Drizzle ORM + * + * This example demonstrates: + * - D1 database with Drizzle ORM adapter + * - Email/password authentication + * - Google and GitHub OAuth + * - Protected routes with session verification + * - CORS configuration for SPA + * - KV storage for sessions (strong consistency) + * - Rate limiting with KV + * + * ⚠️ CRITICAL: better-auth requires Drizzle ORM or Kysely for D1 + * There is NO direct d1Adapter()! + */ + +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1"; +import { rateLimit } from "better-auth/plugins"; +import * as schema from "../db/schema"; // Your Drizzle schema + +// ═══════════════════════════════════════════════════════════════ +// Environment bindings +// ═══════════════════════════════════════════════════════════════ +type Env = { + DB: D1Database; + SESSIONS_KV: KVNamespace; + RATE_LIMIT_KV: KVNamespace; + BETTER_AUTH_SECRET: string; + BETTER_AUTH_URL: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET: string; + FRONTEND_URL: string; +}; + +// Database type +export type Database = DrizzleD1Database; + +const app = new Hono<{ Bindings: Env }>(); + +// ═══════════════════════════════════════════════════════════════ +// CORS configuration for SPA +// ═══════════════════════════════════════════════════════════════ +app.use("/api/*", async (c, next) => { + const corsMiddleware = cors({ + origin: [c.env.FRONTEND_URL, "http://localhost:3000"], + credentials: true, + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization"], + }); + return corsMiddleware(c, next); +}); + +// ═══════════════════════════════════════════════════════════════ +// Helper: Initialize Drizzle database +// ═══════════════════════════════════════════════════════════════ +function createDatabase(d1: D1Database): Database { + return drizzle(d1, { schema }); +} + +// ═══════════════════════════════════════════════════════════════ +// Helper: Initialize auth (per-request to access env) +// ═══════════════════════════════════════════════════════════════ +function createAuth(db: Database, env: Env) { + return betterAuth({ + // Base URL for OAuth callbacks + baseURL: env.BETTER_AUTH_URL, + + // Secret for signing tokens + secret: env.BETTER_AUTH_SECRET, + + // ⚠️ CRITICAL: Use Drizzle adapter with SQLite provider + // There is NO direct d1Adapter()! + database: drizzleAdapter(db, { + provider: "sqlite", + }), + + // Email/password authentication + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + sendVerificationEmail: async ({ user, url, token }) => { + // TODO: Implement email sending + // Use Resend, SendGrid, or Cloudflare Email Routing + console.log(`Verification email for ${user.email}: ${url}`); + console.log(`Verification code: ${token}`); + }, + }, + + // Social providers + socialProviders: { + google: { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + scope: ["openid", "email", "profile"], + }, + github: { + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + scope: ["user:email", "read:user"], + }, + }, + + // Session configuration + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // Update every 24 hours + + // Use KV for sessions (strong consistency vs D1 eventual consistency) + storage: { + get: async (sessionId) => { + const session = await env.SESSIONS_KV.get(sessionId); + return session ? JSON.parse(session) : null; + }, + set: async (sessionId, session, ttl) => { + await env.SESSIONS_KV.put(sessionId, JSON.stringify(session), { + expirationTtl: ttl, + }); + }, + delete: async (sessionId) => { + await env.SESSIONS_KV.delete(sessionId); + }, + }, + }, + + // Plugins + plugins: [ + rateLimit({ + window: 60, // 60 seconds + max: 10, // 10 requests per window + storage: { + get: async (key) => { + return await env.RATE_LIMIT_KV.get(key); + }, + set: async (key, value, ttl) => { + await env.RATE_LIMIT_KV.put(key, value, { + expirationTtl: ttl, + }); + }, + }, + }), + ], + }); +} + +// ═══════════════════════════════════════════════════════════════ +// Auth routes - handle all better-auth endpoints +// ═══════════════════════════════════════════════════════════════ +app.all("/api/auth/*", async (c) => { + const db = createDatabase(c.env.DB); + const auth = createAuth(db, c.env); + return auth.handler(c.req.raw); +}); + +// ═══════════════════════════════════════════════════════════════ +// Example: Protected API route +// ═══════════════════════════════════════════════════════════════ +app.get("/api/protected", async (c) => { + const db = createDatabase(c.env.DB); + const auth = createAuth(db, c.env); + + // Verify session + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + return c.json({ + message: "Protected data", + user: { + id: session.user.id, + email: session.user.email, + name: session.user.name, + }, + }); +}); + +// ═══════════════════════════════════════════════════════════════ +// Example: User profile endpoint +// ═══════════════════════════════════════════════════════════════ +app.get("/api/user/profile", async (c) => { + const db = createDatabase(c.env.DB); + const auth = createAuth(db, c.env); + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + // Fetch additional user data from D1 + const userProfile = await db.query.user.findFirst({ + where: (user, { eq }) => eq(user.id, session.user.id), + }); + + return c.json(userProfile); +}); + +// ═══════════════════════════════════════════════════════════════ +// Example: Update user profile +// ═══════════════════════════════════════════════════════════════ +app.patch("/api/user/profile", async (c) => { + const db = createDatabase(c.env.DB); + const auth = createAuth(db, c.env); + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + const { name } = await c.req.json(); + + // Update user in D1 using Drizzle + await db + .update(schema.user) + .set({ name, updatedAt: new Date() }) + .where(eq(schema.user.id, session.user.id)); + + return c.json({ success: true }); +}); + +// ═══════════════════════════════════════════════════════════════ +// Example: Admin-only endpoint +// ═══════════════════════════════════════════════════════════════ +app.get("/api/admin/users", async (c) => { + const db = createDatabase(c.env.DB); + const auth = createAuth(db, c.env); + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + // Check admin role (you'd store this in users table) + const user = await db.query.user.findFirst({ + where: (user, { eq }) => eq(user.id, session.user.id), + // Add role field to your schema if needed + }); + + // if (user.role !== 'admin') { + // return c.json({ error: 'Forbidden' }, 403) + // } + + // Fetch all users + const users = await db.query.user.findMany({ + columns: { + id: true, + email: true, + name: true, + createdAt: true, + }, + }); + + return c.json(users); +}); + +// ═══════════════════════════════════════════════════════════════ +// Health check +// ═══════════════════════════════════════════════════════════════ +app.get("/health", (c) => { + return c.json({ + status: "ok", + timestamp: new Date().toISOString(), + }); +}); + +// ═══════════════════════════════════════════════════════════════ +// Export Worker +// ═══════════════════════════════════════════════════════════════ +export default app; + +/** + * ═══════════════════════════════════════════════════════════════ + * SETUP CHECKLIST + * ═══════════════════════════════════════════════════════════════ + * + * 1. Create D1 database: + * wrangler d1 create my-app-db + * + * 2. Create KV namespaces: + * wrangler kv:namespace create SESSIONS_KV + * wrangler kv:namespace create RATE_LIMIT_KV + * + * 3. Add to wrangler.toml: + * [[d1_databases]] + * binding = "DB" + * database_name = "my-app-db" + * database_id = "YOUR_ID" + * + * [[kv_namespaces]] + * binding = "SESSIONS_KV" + * id = "YOUR_ID" + * + * [[kv_namespaces]] + * binding = "RATE_LIMIT_KV" + * id = "YOUR_ID" + * + * [vars] + * BETTER_AUTH_URL = "http://localhost:8787" + * FRONTEND_URL = "http://localhost:3000" + * + * 4. Set secrets: + * wrangler secret put BETTER_AUTH_SECRET + * wrangler secret put GOOGLE_CLIENT_ID + * wrangler secret put GOOGLE_CLIENT_SECRET + * wrangler secret put GITHUB_CLIENT_ID + * wrangler secret put GITHUB_CLIENT_SECRET + * + * 5. Generate and apply migrations: + * npx drizzle-kit generate + * wrangler d1 migrations apply my-app-db --local + * wrangler d1 migrations apply my-app-db --remote + * + * 6. Deploy: + * wrangler deploy + * + * ═══════════════════════════════════════════════════════════════ + */ diff --git a/references/cloudflare-worker-kysely.ts b/references/cloudflare-worker-kysely.ts new file mode 100644 index 0000000..afda072 --- /dev/null +++ b/references/cloudflare-worker-kysely.ts @@ -0,0 +1,240 @@ +/** + * Complete Cloudflare Worker with better-auth + Kysely + * + * This example demonstrates: + * - D1 database with Kysely adapter + * - Email/password authentication + * - Google OAuth + * - Protected routes with session verification + * - CORS configuration for SPA + * - CamelCasePlugin for schema conversion + * + * ⚠️ CRITICAL: better-auth requires Kysely (or Drizzle) for D1 + * There is NO direct d1Adapter()! + */ + +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { betterAuth } from "better-auth"; +import { Kysely, CamelCasePlugin } from "kysely"; +import { D1Dialect } from "kysely-d1"; + +// ═══════════════════════════════════════════════════════════════ +// Environment bindings +// ═══════════════════════════════════════════════════════════════ +type Env = { + DB: D1Database; + BETTER_AUTH_SECRET: string; + BETTER_AUTH_URL: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + FRONTEND_URL: string; +}; + +const app = new Hono<{ Bindings: Env }>(); + +// ═══════════════════════════════════════════════════════════════ +// CORS configuration for SPA +// ═══════════════════════════════════════════════════════════════ +app.use("/api/*", async (c, next) => { + const corsMiddleware = cors({ + origin: [c.env.FRONTEND_URL, "http://localhost:3000"], + credentials: true, + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization"], + }); + return corsMiddleware(c, next); +}); + +// ═══════════════════════════════════════════════════════════════ +// Helper: Initialize auth with Kysely +// ═══════════════════════════════════════════════════════════════ +function createAuth(env: Env) { + return betterAuth({ + // Base URL for OAuth callbacks + baseURL: env.BETTER_AUTH_URL, + + // Secret for signing tokens + secret: env.BETTER_AUTH_SECRET, + + // ⚠️ CRITICAL: Use Kysely with D1Dialect + // There is NO direct d1Adapter()! + database: { + db: new Kysely({ + dialect: new D1Dialect({ + database: env.DB, + }), + plugins: [ + // CRITICAL: CamelCasePlugin converts between snake_case (DB) and camelCase (better-auth) + // Without this, session reads will fail if your schema uses snake_case + new CamelCasePlugin(), + ], + }), + type: "sqlite", + }, + + // Email/password authentication + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + sendVerificationEmail: async ({ user, url, token }) => { + // TODO: Implement email sending + console.log(`Verification email for ${user.email}: ${url}`); + console.log(`Verification code: ${token}`); + }, + }, + + // Social providers + socialProviders: { + google: { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + scope: ["openid", "email", "profile"], + }, + }, + + // Session configuration + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // Update every 24 hours + }, + }); +} + +// ═══════════════════════════════════════════════════════════════ +// Auth routes - handle all better-auth endpoints +// ═══════════════════════════════════════════════════════════════ +app.all("/api/auth/*", async (c) => { + const auth = createAuth(c.env); + return auth.handler(c.req.raw); +}); + +// ═══════════════════════════════════════════════════════════════ +// Example: Protected API route +// ═══════════════════════════════════════════════════════════════ +app.get("/api/protected", async (c) => { + const auth = createAuth(c.env); + + // Verify session + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + return c.json({ + message: "Protected data", + user: { + id: session.user.id, + email: session.user.email, + name: session.user.name, + }, + }); +}); + +// ═══════════════════════════════════════════════════════════════ +// Example: User profile endpoint +// ═══════════════════════════════════════════════════════════════ +app.get("/api/user/profile", async (c) => { + const auth = createAuth(c.env); + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + // Fetch user data using Kysely + const db = new Kysely({ + dialect: new D1Dialect({ database: c.env.DB }), + plugins: [new CamelCasePlugin()], + }); + + const user = await db + .selectFrom("user") + .select(["id", "email", "name", "image", "createdAt"]) + .where("id", "=", session.user.id) + .executeTakeFirst(); + + return c.json(user); +}); + +// ═══════════════════════════════════════════════════════════════ +// Health check +// ═══════════════════════════════════════════════════════════════ +app.get("/health", (c) => { + return c.json({ + status: "ok", + timestamp: new Date().toISOString(), + }); +}); + +// ═══════════════════════════════════════════════════════════════ +// Export Worker +// ═══════════════════════════════════════════════════════════════ +export default app; + +/** + * ═══════════════════════════════════════════════════════════════ + * SETUP CHECKLIST + * ═══════════════════════════════════════════════════════════════ + * + * 1. Install dependencies: + * npm install better-auth kysely kysely-d1 hono + * + * 2. Create D1 database: + * wrangler d1 create my-app-db + * + * 3. Add to wrangler.toml: + * [[d1_databases]] + * binding = "DB" + * database_name = "my-app-db" + * database_id = "YOUR_ID" + * + * [vars] + * BETTER_AUTH_URL = "http://localhost:8787" + * FRONTEND_URL = "http://localhost:3000" + * + * 4. Set secrets: + * wrangler secret put BETTER_AUTH_SECRET + * wrangler secret put GOOGLE_CLIENT_ID + * wrangler secret put GOOGLE_CLIENT_SECRET + * + * 5. Create database schema manually (Kysely doesn't auto-generate): + * wrangler d1 execute my-app-db --local --command " + * CREATE TABLE user ( + * id TEXT PRIMARY KEY, + * name TEXT NOT NULL, + * email TEXT NOT NULL UNIQUE, + * email_verified INTEGER NOT NULL DEFAULT 0, + * image TEXT, + * created_at INTEGER NOT NULL DEFAULT (unixepoch()), + * updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + * ); + * CREATE TABLE session (...); + * CREATE TABLE account (...); + * CREATE TABLE verification (...); + * " + * + * 6. Apply schema to remote: + * wrangler d1 execute my-app-db --remote --file schema.sql + * + * 7. Deploy: + * wrangler deploy + * + * ═══════════════════════════════════════════════════════════════ + * WHY CamelCasePlugin? + * ═══════════════════════════════════════════════════════════════ + * + * If your database schema uses snake_case (email_verified), + * but better-auth expects camelCase (emailVerified), the + * CamelCasePlugin automatically converts between the two. + * + * Without it, session reads will fail with missing fields. + * + * ═══════════════════════════════════════════════════════════════ + */ diff --git a/references/database-schema.ts b/references/database-schema.ts new file mode 100644 index 0000000..fe7f953 --- /dev/null +++ b/references/database-schema.ts @@ -0,0 +1,315 @@ +/** + * Complete better-auth Database Schema for Drizzle ORM + D1 + * + * This schema includes all tables required by better-auth core. + * You can add your own application tables below. + * + * ═══════════════════════════════════════════════════════════════ + * CRITICAL NOTES + * ═══════════════════════════════════════════════════════════════ + * + * 1. Column names use camelCase (emailVerified, createdAt) + * - This matches better-auth expectations + * - If you use snake_case, you MUST use CamelCasePlugin with Kysely + * + * 2. Timestamps use INTEGER with mode: "timestamp" + * - D1 (SQLite) doesn't have native timestamp type + * - Unix epoch timestamps (seconds since 1970) + * + * 3. Booleans use INTEGER with mode: "boolean" + * - D1 (SQLite) doesn't have native boolean type + * - 0 = false, 1 = true + * + * 4. Foreign keys use onDelete: "cascade" + * - Automatically delete related records + * - session deleted when user deleted + * - account deleted when user deleted + * + * ═══════════════════════════════════════════════════════════════ + */ + +import { integer, sqliteTable, text, index } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +// ═══════════════════════════════════════════════════════════════ +// better-auth CORE TABLES +// ═══════════════════════════════════════════════════════════════ + +/** + * Users table - stores all user accounts + */ +export const user = sqliteTable( + "user", + { + id: text().primaryKey(), + name: text().notNull(), + email: text().notNull().unique(), + emailVerified: integer({ mode: "boolean" }).notNull().default(false), + image: text(), // Profile picture URL + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => ({ + emailIdx: index("user_email_idx").on(table.email), + }) +); + +/** + * Sessions table - stores active user sessions + * + * NOTE: Consider using KV storage for sessions instead of D1 + * to avoid eventual consistency issues + */ +export const session = sqliteTable( + "session", + { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + token: text().notNull().unique(), + expiresAt: integer({ mode: "timestamp" }).notNull(), + ipAddress: text(), + userAgent: text(), + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => ({ + userIdIdx: index("session_user_id_idx").on(table.userId), + tokenIdx: index("session_token_idx").on(table.token), + }) +); + +/** + * Accounts table - stores OAuth provider accounts and passwords + */ +export const account = sqliteTable( + "account", + { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accountId: text().notNull(), // Provider's user ID + providerId: text().notNull(), // "google", "github", etc. + accessToken: text(), + refreshToken: text(), + accessTokenExpiresAt: integer({ mode: "timestamp" }), + refreshTokenExpiresAt: integer({ mode: "timestamp" }), + scope: text(), // OAuth scopes granted + idToken: text(), // OpenID Connect ID token + password: text(), // Hashed password for email/password auth + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => ({ + userIdIdx: index("account_user_id_idx").on(table.userId), + providerIdx: index("account_provider_idx").on( + table.providerId, + table.accountId + ), + }) +); + +/** + * Verification tokens - for email verification, password reset, etc. + */ +export const verification = sqliteTable( + "verification", + { + id: text().primaryKey(), + identifier: text().notNull(), // Email or user ID + value: text().notNull(), // Token value + expiresAt: integer({ mode: "timestamp" }).notNull(), + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => ({ + identifierIdx: index("verification_identifier_idx").on(table.identifier), + valueIdx: index("verification_value_idx").on(table.value), + }) +); + +// ═══════════════════════════════════════════════════════════════ +// OPTIONAL: Additional tables for better-auth plugins +// ═══════════════════════════════════════════════════════════════ + +/** + * Two-Factor Authentication table (if using 2FA plugin) + */ +export const twoFactor = sqliteTable( + "two_factor", + { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + secret: text().notNull(), // TOTP secret + backupCodes: text(), // JSON array of backup codes + enabled: integer({ mode: "boolean" }).notNull().default(false), + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => ({ + userIdIdx: index("two_factor_user_id_idx").on(table.userId), + }) +); + +/** + * Organizations table (if using organization plugin) + */ +export const organization = sqliteTable("organization", { + id: text().primaryKey(), + name: text().notNull(), + slug: text().notNull().unique(), + logo: text(), + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +/** + * Organization members table (if using organization plugin) + */ +export const organizationMember = sqliteTable( + "organization_member", + { + id: text().primaryKey(), + organizationId: text() + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + role: text().notNull(), // "owner", "admin", "member" + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => ({ + orgIdIdx: index("org_member_org_id_idx").on(table.organizationId), + userIdIdx: index("org_member_user_id_idx").on(table.userId), + }) +); + +// ═══════════════════════════════════════════════════════════════ +// YOUR APPLICATION TABLES +// ═══════════════════════════════════════════════════════════════ + +/** + * Example: User profile extension + */ +export const profile = sqliteTable("profile", { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + bio: text(), + website: text(), + location: text(), + phone: text(), + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +/** + * Example: User preferences + */ +export const userPreferences = sqliteTable("user_preferences", { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + theme: text().notNull().default("system"), // "light", "dark", "system" + language: text().notNull().default("en"), + emailNotifications: integer({ mode: "boolean" }).notNull().default(true), + pushNotifications: integer({ mode: "boolean" }).notNull().default(false), + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +// ═══════════════════════════════════════════════════════════════ +// Export all schemas for Drizzle +// ═══════════════════════════════════════════════════════════════ +export const schema = { + user, + session, + account, + verification, + twoFactor, + organization, + organizationMember, + profile, + userPreferences, +} as const; + +/** + * ═══════════════════════════════════════════════════════════════ + * USAGE INSTRUCTIONS + * ═══════════════════════════════════════════════════════════════ + * + * 1. Save this file as: src/db/schema.ts + * + * 2. Create drizzle.config.ts: + * import type { Config } from "drizzle-kit"; + * + * export default { + * out: "./drizzle", + * schema: "./src/db/schema.ts", + * dialect: "sqlite", + * driver: "d1-http", + * dbCredentials: { + * databaseId: process.env.CLOUDFLARE_DATABASE_ID!, + * accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, + * token: process.env.CLOUDFLARE_TOKEN!, + * }, + * } satisfies Config; + * + * 3. Generate migrations: + * npx drizzle-kit generate + * + * 4. Apply migrations to D1: + * wrangler d1 migrations apply my-app-db --local + * wrangler d1 migrations apply my-app-db --remote + * + * 5. Use in your Worker: + * import { drizzle } from "drizzle-orm/d1"; + * import * as schema from "./db/schema"; + * + * const db = drizzle(env.DB, { schema }); + * + * 6. Query example: + * const users = await db.query.user.findMany({ + * where: (user, { eq }) => eq(user.emailVerified, true) + * }); + * + * ═══════════════════════════════════════════════════════════════ + */ diff --git a/references/nextjs/README.md b/references/nextjs/README.md new file mode 100644 index 0000000..d98227a --- /dev/null +++ b/references/nextjs/README.md @@ -0,0 +1,41 @@ +# Next.js Examples + +This directory contains better-auth examples for **Next.js with PostgreSQL**. + +**Important**: These examples are NOT for Cloudflare D1. They use PostgreSQL via Hyperdrive or direct connection. + +## Files + +### `postgres-example.ts` +Complete Next.js API route with better-auth using: +- **PostgreSQL** (not D1) +- Drizzle ORM with `postgres` driver +- Organizations plugin +- 2FA plugin +- Email verification +- Custom error handling + +**Use this example when**: +- Building Next.js application (not Cloudflare Workers) +- Using PostgreSQL database +- Need organizations and 2FA features + +**Installation**: +```bash +npm install better-auth drizzle-orm postgres +``` + +**Environment variables**: +```env +DATABASE_URL=postgresql://user:password@host:5432/database +BETTER_AUTH_SECRET=your-secret +NEXT_PUBLIC_APP_URL=http://localhost:3000 +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +``` + +--- + +**For Cloudflare D1 examples**, see the parent `references/` directory: +- `cloudflare-worker-drizzle.ts` - Complete Worker with Drizzle + D1 +- `cloudflare-worker-kysely.ts` - Complete Worker with Kysely + D1 diff --git a/references/nextjs/postgres-example.ts b/references/nextjs/postgres-example.ts new file mode 100644 index 0000000..e1e5407 --- /dev/null +++ b/references/nextjs/postgres-example.ts @@ -0,0 +1,147 @@ +/** + * Next.js API Route with better-auth + * + * This example demonstrates: + * - PostgreSQL with Drizzle ORM + * - Email/password + social auth + * - Email verification + * - Organizations plugin + * - 2FA plugin + * - Custom error handling + */ + +import { betterAuth } from 'better-auth' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { twoFactor, organization } from 'better-auth/plugins' +import { sendEmail } from '@/lib/email' // Your email service + +// Database connection +const client = postgres(process.env.DATABASE_URL!) +const db = drizzle(client) + +// Initialize better-auth +export const auth = betterAuth({ + database: db, + + secret: process.env.BETTER_AUTH_SECRET!, + + baseURL: process.env.NEXT_PUBLIC_APP_URL!, + + // Email/password authentication + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + + // Custom email sending + sendVerificationEmail: async ({ user, url, token }) => { + await sendEmail({ + to: user.email, + subject: 'Verify your email', + html: ` +

Verify your email

+

Click the link below to verify your email address:

+ Verify Email +

Or enter this code: ${token}

+

This link expires in 24 hours.

+ ` + }) + }, + + // Password reset email + sendResetPasswordEmail: async ({ user, url, token }) => { + await sendEmail({ + to: user.email, + subject: 'Reset your password', + html: ` +

Reset your password

+

Click the link below to reset your password:

+ Reset Password +

Or enter this code: ${token}

+

This link expires in 1 hour.

+ ` + }) + } + }, + + // Social providers + socialProviders: { + google: { + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + scope: ['openid', 'email', 'profile'] + }, + github: { + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + scope: ['user:email', 'read:user'] + }, + microsoft: { + clientId: process.env.MICROSOFT_CLIENT_ID!, + clientSecret: process.env.MICROSOFT_CLIENT_SECRET!, + tenantId: process.env.MICROSOFT_TENANT_ID || 'common' + } + }, + + // Session configuration + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // Update every 24 hours + cookieCache: { + enabled: true, + maxAge: 60 * 5 // 5 minutes + } + }, + + // Advanced features via plugins + plugins: [ + // Two-factor authentication + twoFactor({ + methods: ['totp', 'sms'], + issuer: 'MyApp', + sendOTP: async ({ user, otp, method }) => { + if (method === 'sms') { + // Send SMS with OTP (use Twilio, etc.) + console.log(`Send SMS to ${user.phone}: ${otp}`) + } + } + }), + + // Organizations and teams + organization({ + roles: ['owner', 'admin', 'member'], + permissions: { + owner: ['*'], // All permissions + admin: ['read', 'write', 'delete', 'invite'], + member: ['read'] + }, + sendInvitationEmail: async ({ email, organizationName, inviteUrl }) => { + await sendEmail({ + to: email, + subject: `You've been invited to ${organizationName}`, + html: ` +

You've been invited!

+

Click the link below to join ${organizationName}:

+ Accept Invitation + ` + }) + } + }) + ], + + // Custom error handling + onError: (error, req) => { + console.error('Auth error:', error) + // Log to your error tracking service (Sentry, etc.) + }, + + // Success callbacks + onSuccess: async (user, action) => { + console.log(`User ${user.id} performed action: ${action}`) + // Log auth events for security monitoring + } +}) + +// Type definitions for TypeScript +export type Session = typeof auth.$Infer.Session +export type User = typeof auth.$Infer.User diff --git a/references/react-client-hooks.tsx b/references/react-client-hooks.tsx new file mode 100644 index 0000000..70391b4 --- /dev/null +++ b/references/react-client-hooks.tsx @@ -0,0 +1,470 @@ +/** + * React Client Components with better-auth + * + * This example demonstrates: + * - useSession hook + * - Sign in/up forms + * - Social sign-in buttons + * - Protected route component + * - User profile component + * - Organization switcher + */ + +'use client' + +import { createAuthClient, useSession } from 'better-auth/client' +import { useState, useEffect } from 'react' + +// Initialize auth client +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000' +}) + +// ============================================================================ +// Login Form Component +// ============================================================================ + +export function LoginForm() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleEmailSignIn = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + const { data, error } = await authClient.signIn.email({ + email, + password + }) + + if (error) { + setError(error.message) + return + } + + // Redirect on success + window.location.href = '/dashboard' + } catch (err) { + setError('An error occurred. Please try again.') + } finally { + setLoading(false) + } + } + + const handleGoogleSignIn = async () => { + setLoading(true) + await authClient.signIn.social({ + provider: 'google', + callbackURL: '/dashboard' + }) + } + + const handleGitHubSignIn = async () => { + setLoading(true) + await authClient.signIn.social({ + provider: 'github', + callbackURL: '/dashboard' + }) + } + + return ( +
+

Sign In

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border rounded-md" + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + className="w-full px-3 py-2 border rounded-md" + placeholder="••••••••" + /> +
+ + +
+ +
+
+
+
+
+
+ Or continue with +
+
+ +
+ + +
+
+ +

+ Don't have an account?{' '} + + Sign up + +

+
+ ) +} + +// ============================================================================ +// Sign Up Form Component +// ============================================================================ + +export function SignUpForm() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [name, setName] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState(false) + + const handleSignUp = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + const { data, error } = await authClient.signUp.email({ + email, + password, + name + }) + + if (error) { + setError(error.message) + return + } + + setSuccess(true) + } catch (err) { + setError('An error occurred. Please try again.') + } finally { + setLoading(false) + } + } + + if (success) { + return ( +
+

Check your email

+

+ We've sent a verification link to {email}. + Click the link to verify your account. +

+
+ ) + } + + return ( +
+

Sign Up

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setName(e.target.value)} + required + className="w-full px-3 py-2 border rounded-md" + placeholder="John Doe" + /> +
+ +
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border rounded-md" + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + className="w-full px-3 py-2 border rounded-md" + placeholder="••••••••" + /> +

+ At least 8 characters +

+
+ + +
+ +

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

+
+ ) +} + +// ============================================================================ +// User Profile Component +// ============================================================================ + +export function UserProfile() { + const { data: session, isPending } = useSession() + + if (isPending) { + return
Loading...
+ } + + if (!session) { + return ( +
+

Not authenticated

+ + Sign in + +
+ ) + } + + const handleSignOut = async () => { + await authClient.signOut() + window.location.href = '/login' + } + + return ( +
+
+ {session.user.image && ( + {session.user.name + )} +
+

{session.user.name}

+

{session.user.email}

+
+ +
+
+ ) +} + +// ============================================================================ +// Protected Route Component +// ============================================================================ + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { data: session, isPending } = useSession() + + if (isPending) { + return ( +
+
+
+

Loading...

+
+
+ ) + } + + if (!session) { + // Redirect to login + if (typeof window !== 'undefined') { + window.location.href = '/login' + } + return null + } + + return <>{children} +} + +// ============================================================================ +// Organization Switcher Component (if using organizations plugin) +// ============================================================================ + +export function OrganizationSwitcher() { + const { data: session } = useSession() + const [organizations, setOrganizations] = useState([]) + const [loading, setLoading] = useState(true) + + // Fetch user's organizations + useEffect(() => { + async function fetchOrgs() { + const orgs = await authClient.organization.listUserOrganizations() + setOrganizations(orgs) + setLoading(false) + } + fetchOrgs() + }, []) + + const switchOrganization = async (orgId: string) => { + await authClient.organization.setActiveOrganization({ organizationId: orgId }) + window.location.reload() + } + + if (loading) return
Loading organizations...
+ + return ( + + ) +} + +// ============================================================================ +// 2FA Setup Component (if using twoFactor plugin) +// ============================================================================ + +export function TwoFactorSetup() { + const [qrCode, setQrCode] = useState('') + const [verifyCode, setVerifyCode] = useState('') + const [enabled, setEnabled] = useState(false) + + const enable2FA = async () => { + const { data } = await authClient.twoFactor.enable({ method: 'totp' }) + setQrCode(data.qrCode) + } + + const verify2FA = async (e: React.FormEvent) => { + e.preventDefault() + const { error } = await authClient.twoFactor.verify({ code: verifyCode }) + if (!error) { + setEnabled(true) + } + } + + if (enabled) { + return
2FA is enabled!
+ } + + if (qrCode) { + return ( +
+

Scan QR Code

+ 2FA QR Code +
+ setVerifyCode(e.target.value)} + placeholder="Enter 6-digit code" + className="w-full px-3 py-2 border rounded-md" + maxLength={6} + /> + +
+
+ ) + } + + return ( + + ) +} diff --git a/scripts/setup-d1-drizzle.sh b/scripts/setup-d1-drizzle.sh new file mode 100644 index 0000000..7b09428 --- /dev/null +++ b/scripts/setup-d1-drizzle.sh @@ -0,0 +1,221 @@ +#!/bin/bash + +# ═══════════════════════════════════════════════════════════════ +# better-auth + D1 + Drizzle ORM Setup Script +# ═══════════════════════════════════════════════════════════════ +# +# This script automates the setup of better-auth with Cloudflare D1 +# and Drizzle ORM. +# +# CRITICAL: better-auth requires Drizzle ORM or Kysely for D1. +# There is NO direct d1Adapter()! +# +# Usage: +# chmod +x setup-d1-drizzle.sh +# ./setup-d1-drizzle.sh my-app-name +# +# ═══════════════════════════════════════════════════════════════ + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Check if app name provided +if [ -z "$1" ]; then + echo -e "${RED}Error: Please provide an app name${NC}" + echo "Usage: ./setup-d1-drizzle.sh my-app-name" + exit 1 +fi + +APP_NAME=$1 +DB_NAME="${APP_NAME}-db" + +echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" +echo -e "${BLUE}better-auth + D1 + Drizzle Setup${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" +echo "" +echo -e "App Name: ${GREEN}$APP_NAME${NC}" +echo -e "Database: ${GREEN}$DB_NAME${NC}" +echo "" + +# ═══════════════════════════════════════════════════════════════ +# Step 1: Install dependencies +# ═══════════════════════════════════════════════════════════════ +echo -e "${YELLOW}[1/8]${NC} Installing dependencies..." +npm install better-auth drizzle-orm drizzle-kit @cloudflare/workers-types hono + +# ═══════════════════════════════════════════════════════════════ +# Step 2: Create D1 database +# ═══════════════════════════════════════════════════════════════ +echo -e "${YELLOW}[2/8]${NC} Creating D1 database..." +wrangler d1 create $DB_NAME + +echo -e "${GREEN}✓${NC} Database created!" +echo -e "${YELLOW}⚠${NC} Copy the database_id from the output above and update wrangler.toml" +echo "" +read -p "Press Enter after updating wrangler.toml..." + +# ═══════════════════════════════════════════════════════════════ +# Step 3: Create directory structure +# ═══════════════════════════════════════════════════════════════ +echo -e "${YELLOW}[3/8]${NC} Creating directory structure..." +mkdir -p src/db +mkdir -p drizzle + +# ═══════════════════════════════════════════════════════════════ +# Step 4: Create database schema +# ═══════════════════════════════════════════════════════════════ +echo -e "${YELLOW}[4/8]${NC} Creating database schema..." +cat > src/db/schema.ts << 'EOF' +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +export const user = sqliteTable("user", { + id: text().primaryKey(), + name: text().notNull(), + email: text().notNull().unique(), + emailVerified: integer({ mode: "boolean" }).notNull().default(false), + image: text(), + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export const session = sqliteTable("session", { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + token: text().notNull(), + expiresAt: integer({ mode: "timestamp" }).notNull(), + ipAddress: text(), + userAgent: text(), + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export const account = sqliteTable("account", { + id: text().primaryKey(), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accountId: text().notNull(), + providerId: text().notNull(), + accessToken: text(), + refreshToken: text(), + accessTokenExpiresAt: integer({ mode: "timestamp" }), + refreshTokenExpiresAt: integer({ mode: "timestamp" }), + scope: text(), + idToken: text(), + password: text(), + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export const verification = sqliteTable("verification", { + id: text().primaryKey(), + identifier: text().notNull(), + value: text().notNull(), + expiresAt: integer({ mode: "timestamp" }).notNull(), + createdAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer({ mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); +EOF + +# ═══════════════════════════════════════════════════════════════ +# Step 5: Create Drizzle config +# ═══════════════════════════════════════════════════════════════ +echo -e "${YELLOW}[5/8]${NC} Creating Drizzle config..." +cat > drizzle.config.ts << 'EOF' +import type { Config } from "drizzle-kit"; + +export default { + out: "./drizzle", + schema: "./src/db/schema.ts", + dialect: "sqlite", + driver: "d1-http", + dbCredentials: { + databaseId: process.env.CLOUDFLARE_DATABASE_ID!, + accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, + token: process.env.CLOUDFLARE_TOKEN!, + }, +} satisfies Config; +EOF + +echo -e "${GREEN}✓${NC} Config created" +echo "" +echo -e "${YELLOW}⚠${NC} Create a .env file with:" +echo " CLOUDFLARE_ACCOUNT_ID=your-account-id" +echo " CLOUDFLARE_DATABASE_ID=your-database-id" +echo " CLOUDFLARE_TOKEN=your-api-token" +echo "" +read -p "Press Enter after creating .env..." + +# ═══════════════════════════════════════════════════════════════ +# Step 6: Generate migrations +# ═══════════════════════════════════════════════════════════════ +echo -e "${YELLOW}[6/8]${NC} Generating migrations..." +npx drizzle-kit generate + +# ═══════════════════════════════════════════════════════════════ +# Step 7: Apply migrations +# ═══════════════════════════════════════════════════════════════ +echo -e "${YELLOW}[7/8]${NC} Applying migrations..." +echo -e "Applying to ${GREEN}local${NC} D1..." +wrangler d1 migrations apply $DB_NAME --local + +read -p "Apply to remote D1 too? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo -e "Applying to ${GREEN}remote${NC} D1..." + wrangler d1 migrations apply $DB_NAME --remote +fi + +# ═══════════════════════════════════════════════════════════════ +# Step 8: Set secrets +# ═══════════════════════════════════════════════════════════════ +echo -e "${YELLOW}[8/8]${NC} Setting secrets..." +echo "" +echo -e "${BLUE}Generating BETTER_AUTH_SECRET...${NC}" +SECRET=$(openssl rand -base64 32) +echo "$SECRET" | wrangler secret put BETTER_AUTH_SECRET + +echo "" +echo -e "${GREEN}✓ Setup complete!${NC}" +echo "" +echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" +echo -e "${BLUE}Next Steps${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" +echo "" +echo "1. Add OAuth client IDs and secrets (if needed):" +echo " wrangler secret put GOOGLE_CLIENT_ID" +echo " wrangler secret put GOOGLE_CLIENT_SECRET" +echo "" +echo "2. Test locally:" +echo " npm run dev" +echo "" +echo "3. Deploy:" +echo " wrangler deploy" +echo "" +echo -e "${YELLOW}⚠ IMPORTANT:${NC} Update your wrangler.toml with the database_id from Step 2" +echo ""