Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -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": [
|
||||||
|
"./"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -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.
|
||||||
370
assets/auth-flow-diagram.md
Normal file
370
assets/auth-flow-diagram.md
Normal file
@@ -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.
|
||||||
77
plugin.lock.json
Normal file
77
plugin.lock.json
Normal file
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
334
references/cloudflare-worker-drizzle.ts
Normal file
334
references/cloudflare-worker-drizzle.ts
Normal file
@@ -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<typeof schema>;
|
||||||
|
|
||||||
|
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
|
||||||
|
*
|
||||||
|
* ═══════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
240
references/cloudflare-worker-kysely.ts
Normal file
240
references/cloudflare-worker-kysely.ts
Normal file
@@ -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.
|
||||||
|
*
|
||||||
|
* ═══════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
315
references/database-schema.ts
Normal file
315
references/database-schema.ts
Normal file
@@ -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)
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* ═══════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
41
references/nextjs/README.md
Normal file
41
references/nextjs/README.md
Normal file
@@ -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
|
||||||
147
references/nextjs/postgres-example.ts
Normal file
147
references/nextjs/postgres-example.ts
Normal file
@@ -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: `
|
||||||
|
<h1>Verify your email</h1>
|
||||||
|
<p>Click the link below to verify your email address:</p>
|
||||||
|
<a href="${url}">Verify Email</a>
|
||||||
|
<p>Or enter this code: <strong>${token}</strong></p>
|
||||||
|
<p>This link expires in 24 hours.</p>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Password reset email
|
||||||
|
sendResetPasswordEmail: async ({ user, url, token }) => {
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: 'Reset your password',
|
||||||
|
html: `
|
||||||
|
<h1>Reset your password</h1>
|
||||||
|
<p>Click the link below to reset your password:</p>
|
||||||
|
<a href="${url}">Reset Password</a>
|
||||||
|
<p>Or enter this code: <strong>${token}</strong></p>
|
||||||
|
<p>This link expires in 1 hour.</p>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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: `
|
||||||
|
<h1>You've been invited!</h1>
|
||||||
|
<p>Click the link below to join ${organizationName}:</p>
|
||||||
|
<a href="${inviteUrl}">Accept Invitation</a>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
|
||||||
|
// 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
|
||||||
470
references/react-client-hooks.tsx
Normal file
470
references/react-client-hooks.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Sign In</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleEmailSignIn} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
|
disabled={loading}
|
||||||
|
className="py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Google
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGitHubSignIn}
|
||||||
|
disabled={loading}
|
||||||
|
className="py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-600">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<a href="/signup" className="text-blue-600 hover:underline">
|
||||||
|
Sign up
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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 (
|
||||||
|
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Check your email</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
We've sent a verification link to <strong>{email}</strong>.
|
||||||
|
Click the link to verify your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Sign Up</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSignUp} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
At least 8 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating account...' : 'Sign Up'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-600">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<a href="/login" className="text-blue-600 hover:underline">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// User Profile Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function UserProfile() {
|
||||||
|
const { data: session, isPending } = useSession()
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <div className="p-4">Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<p>Not authenticated</p>
|
||||||
|
<a href="/login" className="text-blue-600 hover:underline">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await authClient.signOut()
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-white rounded-lg shadow">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{session.user.image && (
|
||||||
|
<img
|
||||||
|
src={session.user.image}
|
||||||
|
alt={session.user.name || 'User'}
|
||||||
|
className="w-12 h-12 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">{session.user.name}</h3>
|
||||||
|
<p className="text-sm text-gray-600">{session.user.email}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="px-4 py-2 text-sm border rounded-md hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Protected Route Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { data: session, isPending } = useSession()
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
|
||||||
|
<p className="mt-4 text-gray-600">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div>Loading organizations...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
onChange={(e) => switchOrganization(e.target.value)}
|
||||||
|
className="px-3 py-2 border rounded-md"
|
||||||
|
>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<option key={org.id} value={org.id}>
|
||||||
|
{org.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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 <div className="p-4 bg-green-100 rounded">2FA is enabled!</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qrCode) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-white rounded-lg shadow">
|
||||||
|
<h3 className="font-semibold mb-4">Scan QR Code</h3>
|
||||||
|
<img src={qrCode} alt="2FA QR Code" className="mx-auto mb-4" />
|
||||||
|
<form onSubmit={verify2FA} className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={verifyCode}
|
||||||
|
onChange={(e) => setVerifyCode(e.target.value)}
|
||||||
|
placeholder="Enter 6-digit code"
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md"
|
||||||
|
>
|
||||||
|
Verify & Enable
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={enable2FA}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md"
|
||||||
|
>
|
||||||
|
Enable 2FA
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
221
scripts/setup-d1-drizzle.sh
Normal file
221
scripts/setup-d1-drizzle.sh
Normal file
@@ -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 ""
|
||||||
Reference in New Issue
Block a user