Skip to content

Commit 068c37e

Browse files
authored
feat: Implement passkey and social authentication (#2107)
1 parent b9657b6 commit 068c37e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

73 files changed

+3723
-1757
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Validate Auth Schema
2+
3+
Validate that the Drizzle ORM schema in `db/schema/` matches the Better Auth requirements.
4+
5+
## Steps
6+
7+
1. **Generate Better Auth schema reference**:
8+
9+
```bash
10+
bun run db/scripts/generate-auth-schema.ts
11+
```
12+
13+
2. **Compare with current Drizzle schema**:
14+
- Review all files in `db/schema/` (user.ts, organization.ts, etc.)
15+
- Check that each Better Auth table has corresponding Drizzle table
16+
- Verify field types, constraints, and relationships match
17+
- Ensure table names and field names align with Better Auth expectations
18+
19+
3. **Key validation points**:
20+
- **Table mapping**: Better Auth `account` → Drizzle `identity`
21+
- **Required fields**: All Better Auth required fields are present and correctly typed
22+
- **Relationships**: Foreign key references match (userId, organizationId, etc.)
23+
- **Constraints**: Unique fields, required fields, default values
24+
- **Field types**: string/text, boolean, date/timestamp, number types
25+
26+
4. **Report findings**:
27+
- List any missing tables or fields
28+
- Identify type mismatches
29+
- Note incorrect constraints or relationships
30+
- Suggest specific fixes needed
31+
32+
## Context
33+
34+
Better Auth requires specific database schema structure. The generated JSON serves as the source of truth for what Better Auth expects, while the Drizzle schema in `db/schema/` is our actual implementation that must match.
35+
36+
## Success Criteria
37+
38+
- All Better Auth required tables exist in Drizzle schema
39+
- Field types and constraints match Better Auth requirements
40+
- Foreign key relationships are correctly implemented
41+
- Custom schema additions (like organizations) don't conflict with Better Auth expectations

.gemini/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"preferredEditor": "vscode",
3-
"contextFileName": "CLAUDE.md"
3+
"contextFileName": ["CLAUDE.md", "CLAUDE.local.md"]
44
}

apps/api/dev.ts

Lines changed: 46 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,89 @@
11
/**
2-
* Local development server for the API with Neon database support via Hyperdrive.
2+
* Local development server that emulates Cloudflare Workers runtime with Neon database.
33
*
4-
* @remarks
5-
* This file bootstraps a local Cloudflare Workers environment using Wrangler's
6-
* getPlatformProxy, allowing you to develop against a Neon database instance
7-
* locally with Hyperdrive bindings.
8-
*
9-
* Features:
10-
* - Emulates Cloudflare Workers runtime environment
11-
* - Provides access to Hyperdrive bindings for Neon PostgreSQL
12-
* - Connection pooling via Hyperdrive
13-
* - Hot reloading support for rapid development
4+
* WARNING: This file uses getPlatformProxy which requires wrangler.jsonc configuration.
5+
* Hyperdrive bindings must be configured for both HYPERDRIVE_CACHED and HYPERDRIVE_DIRECT.
146
*
157
* @example
16-
* Start the development server:
178
* ```bash
189
* bun --filter @repo/api dev
10+
* bun --filter @repo/api dev --env=staging # Use staging environment config
1911
* ```
2012
*
2113
* SPDX-FileCopyrightText: 2014-present Kriasoft
2214
* SPDX-License-Identifier: MIT
2315
*/
2416

2517
import { Hono } from "hono";
18+
import { parseArgs } from "node:util";
2619
import { getPlatformProxy } from "wrangler";
2720
import api from "./index.js";
2821
import { createAuth } from "./lib/auth.js";
2922
import type { AppContext } from "./lib/context.js";
3023
import { createDb } from "./lib/db.js";
3124
import type { Env } from "./lib/env.js";
3225

26+
const { values: args } = parseArgs({
27+
args: Bun.argv.slice(2),
28+
options: {
29+
env: { type: "string" },
30+
},
31+
});
32+
3333
type CloudflareEnv = {
3434
HYPERDRIVE_CACHED: Hyperdrive;
3535
HYPERDRIVE_DIRECT: Hyperdrive;
3636
} & Env;
3737

38-
/**
39-
* Initialize the local development server with Cloudflare bindings.
40-
*/
38+
// [INITIALIZATION]
4139
const app = new Hono<AppContext>();
4240

43-
/**
44-
* Create a local Cloudflare environment proxy.
45-
*
46-
* @remarks
47-
* - Reads configuration from wrangler.jsonc in the parent directory
48-
* - Enables persistence to maintain D1 database state across restarts
49-
* - Provides access to all Cloudflare bindings defined in wrangler.jsonc
50-
*/
41+
// NOTE: persist:true maintains D1 state across restarts (.wrangler directory)
42+
// Environment defaults to 'dev' unless --env flag is provided
5143
const cf = await getPlatformProxy<CloudflareEnv>({
5244
configPath: "./wrangler.jsonc",
45+
environment: args.env ?? "dev",
5346
persist: true,
5447
});
5548

56-
/**
57-
* Middleware to inject database binding into request context.
58-
*
59-
* @remarks
60-
* Uses Neon PostgreSQL via Hyperdrive for both local development
61-
* and production deployment with connection pooling and caching.
62-
*/
49+
// [CONTEXT INJECTION]
50+
// Creates two database connections per request:
51+
// - db: Uses Hyperdrive caching (read-heavy queries)
52+
// - dbDirect: Bypasses cache (write operations, transactions)
6353
app.use("*", async (c, next) => {
6454
const db = createDb(cf.env.HYPERDRIVE_CACHED);
6555
const dbDirect = createDb(cf.env.HYPERDRIVE_DIRECT);
56+
57+
// Priority: Cloudflare bindings > process.env > empty string
58+
// Required for local dev where secrets aren't in wrangler.jsonc
59+
const secretKeys = [
60+
"BETTER_AUTH_SECRET",
61+
"GOOGLE_CLIENT_ID",
62+
"GOOGLE_CLIENT_SECRET",
63+
"OPENAI_API_KEY",
64+
] as const;
65+
66+
const env = {
67+
...cf.env,
68+
...Object.fromEntries(
69+
secretKeys.map((key) => [key, (cf.env[key] || process.env[key]) ?? ""]),
70+
),
71+
APP_NAME: cf.env.APP_NAME || process.env.APP_NAME || "Example",
72+
APP_ORIGIN:
73+
// Prefer origin set by `apps/app` at runtime
74+
c.req.header("x-forwarded-origin") ||
75+
c.env.APP_ORIGIN ||
76+
process.env.APP_ORIGIN ||
77+
"http://localhost:5173",
78+
};
79+
6680
c.set("db", db);
6781
c.set("dbDirect", dbDirect);
68-
c.set("auth", createAuth(db, cf.env));
82+
c.set("auth", createAuth(db, env));
6983
await next();
7084
});
7185

72-
/**
73-
* Mount the main API routes.
74-
* All routes defined in ./lib/app.ts will be available at the root path.
75-
*/
86+
// Routes from ./index.js mounted at root
7687
app.route("/", api);
7788

7889
export default app;

apps/api/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ export { createDb } from "./lib/db.js";
1717
export { default as app, appRouter } from "./lib/app.js";
1818

1919
// Type exports
20-
export type { AppContext } from "./lib/context.js";
2120
export type { AppRouter } from "./lib/app.js";
21+
export type { AppContext } from "./lib/context.js";
22+
// Re-export context type to fix TypeScript portability issues
23+
export type * from "./lib/context.js";
2224

2325
// Default export is the core app
2426
export { default } from "./lib/app.js";

apps/api/lib/auth.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { betterAuth } from "better-auth";
66
import type { DB } from "better-auth/adapters/drizzle";
77
import { drizzleAdapter } from "better-auth/adapters/drizzle";
88
import { anonymous, organization } from "better-auth/plugins";
9+
import { passkey } from "better-auth/plugins/passkey";
910
import type { Env } from "./env";
1011

1112
/**
@@ -14,7 +15,11 @@ import type { Env } from "./env";
1415
*/
1516
type AuthEnv = Pick<
1617
Env,
17-
"BETTER_AUTH_SECRET" | "GOOGLE_CLIENT_ID" | "GOOGLE_CLIENT_SECRET"
18+
| "APP_NAME"
19+
| "APP_ORIGIN"
20+
| "BETTER_AUTH_SECRET"
21+
| "GOOGLE_CLIENT_ID"
22+
| "GOOGLE_CLIENT_SECRET"
1823
>;
1924

2025
/**
@@ -44,7 +49,13 @@ export function createAuth(
4449
db: DB,
4550
env: AuthEnv,
4651
): ReturnType<typeof betterAuth> {
52+
// Extract domain from APP_ORIGIN for passkey rpID
53+
const appUrl = new URL(env.APP_ORIGIN);
54+
const rpID = appUrl.hostname;
55+
4756
return betterAuth({
57+
baseURL: `${env.APP_ORIGIN}/api/auth`,
58+
trustedOrigins: [env.APP_ORIGIN],
4859
secret: env.BETTER_AUTH_SECRET,
4960
database: drizzleAdapter(db, {
5061
provider: "pg",
@@ -54,6 +65,7 @@ export function createAuth(
5465
invitation: Db.invitation,
5566
member: Db.member,
5667
organization: Db.organization,
68+
passkey: Db.passkey,
5769
session: Db.session,
5870
user: Db.user,
5971
verification: Db.verification,
@@ -84,6 +96,14 @@ export function createAuth(
8496
organizationLimit: 5,
8597
creatorRole: "owner",
8698
}),
99+
passkey({
100+
// rpID: Relying Party ID - domain name in production, 'localhost' for dev
101+
rpID,
102+
// rpName: Human-readable name for your app
103+
rpName: env.APP_NAME,
104+
// origin: URL where auth occurs (no trailing slash)
105+
origin: env.APP_ORIGIN,
106+
}),
87107
],
88108

89109
advanced: {

apps/api/lib/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { z } from "zod";
1111
*/
1212
export const envSchema = z.object({
1313
ENVIRONMENT: z.enum(["production", "staging", "preview", "development"]),
14+
APP_NAME: z.string().default("Example"),
15+
APP_ORIGIN: z.url(),
1416
DATABASE_URL: z.url(),
1517
BETTER_AUTH_SECRET: z.string().min(32),
1618
GOOGLE_CLIENT_ID: z.string(),

apps/api/package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,24 @@
2121
"@repo/core": "workspace:*",
2222
"@repo/db": "workspace:*",
2323
"@trpc/server": "^11.5.0",
24-
"ai": "^5.0.28",
25-
"better-auth": "^1.3.7",
24+
"ai": "^5.0.30",
25+
"better-auth": "^1.3.8",
2626
"dataloader": "^2.2.3",
2727
"drizzle-orm": "^0.44.5",
2828
"postgres": "^3.4.7"
2929
},
3030
"peerDependencies": {
31-
"hono": "^4.9.5",
31+
"hono": "^4.9.6",
3232
"zod": "^4.1.5"
3333
},
3434
"devDependencies": {
35-
"@cloudflare/workers-types": "^4.20250829.0",
35+
"@cloudflare/workers-types": "^4.20250904.0",
3636
"@repo/typescript-config": "workspace:*",
3737
"@types/bun": "^1.2.21",
38-
"hono": "^4.9.5",
38+
"hono": "^4.9.6",
3939
"typescript": "~5.9.2",
4040
"vitest": "~3.2.4",
41-
"wrangler": "^4.33.1",
41+
"wrangler": "^4.33.2",
4242
"zod": "^4.1.5"
4343
}
4444
}

apps/api/wrangler.jsonc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
],
2727
"vars": {
2828
"ENVIRONMENT": "production",
29+
"APP_NAME": "Example",
30+
"APP_ORIGIN": "https://example.com",
2931
"ALLOWED_ORIGINS": "https://example.com"
3032
},
3133
// prettier-ignore
@@ -41,6 +43,8 @@
4143
"dev": {
4244
"vars": {
4345
"ENVIRONMENT": "development",
46+
"APP_NAME": "Example",
47+
"APP_ORIGIN": "http://localhost:5173",
4448
"ALLOWED_ORIGINS": "http://localhost:5173,http://127.0.0.1:5173"
4549
},
4650
// prettier-ignore
@@ -61,6 +65,8 @@
6165
],
6266
"vars": {
6367
"ENVIRONMENT": "staging",
68+
"APP_NAME": "Example",
69+
"APP_ORIGIN": "https://staging.example.com",
6470
"ALLOWED_ORIGINS": "https://staging.example.com"
6571
},
6672
// prettier-ignore
@@ -80,6 +86,8 @@
8086
],
8187
"vars": {
8288
"ENVIRONMENT": "preview",
89+
"APP_NAME": "Example",
90+
"APP_ORIGIN": "https://preview.example.com",
8391
"ALLOWED_ORIGINS": "https://preview.example.com"
8492
},
8593
// prettier-ignore

apps/app/CLAUDE.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## Architecture
2+
3+
This is a Single Page Application (SPA) and does not require Server-Side Rendering (SSR). All rendering is done on the client-side.
4+
15
## Tech Stack
26

37
React 19, TypeScript, Vite, TanStack Router, shadcn/ui, Tailwind CSS v4, Jotai, Better Auth.
@@ -14,3 +18,48 @@ React 19, TypeScript, Vite, TanStack Router, shadcn/ui, Tailwind CSS v4, Jotai,
1418
- `components/` - Reusable UI components
1519
- `lib/` - Utilities and shared logic
1620
- `styles/` - Global CSS and Tailwind config
21+
22+
## Routing Conventions
23+
24+
### Route Groups
25+
26+
- `(app)/` - Protected routes requiring authentication
27+
- `(auth)/` - Public authentication routes
28+
- Parentheses create logical groups without affecting URLs
29+
30+
### Components
31+
32+
- Layout components use `<Outlet />` for nested routes
33+
- Access route context directly via `Route.useRouteContext()`, not props
34+
- Import route definitions for type-safe context access: `import { Route } from "@/routes/(app)/route"`
35+
36+
### Navigation
37+
38+
- Use `<Link>` from TanStack Router for internal routes
39+
- Use `<a>` for external links or undefined routes
40+
- Active styling via `activeProps` on `<Link>`
41+
42+
### Authentication
43+
44+
#### Core Decisions
45+
46+
- **Provider:** Better Auth (cookie-based sessions, OAuth, passkeys)
47+
- **State:** TanStack Query wraps all auth calls via `useSessionQuery()` / `useSuspenseSessionQuery()`
48+
- **Protection:** Routes validate auth in `beforeLoad` hooks, not components
49+
- **Storage:** Server sessions only, no localStorage/sessionStorage
50+
51+
#### Implementation Rules
52+
53+
- Never use Better Auth's `useSession()` directly - use TanStack Query wrappers
54+
- Route groups: `(app)/` = protected, `(auth)/` = public
55+
- Auth checks validate both `session?.user` AND `session?.session` exist
56+
- Session queries cached 30s, auto-refresh on focus/reconnect
57+
- Invalidate session cache after login/logout for fresh data
58+
- Auth errors (401/403) trigger redirects via error boundaries
59+
60+
#### Files
61+
62+
- `lib/auth.ts` - Better Auth client setup
63+
- `lib/queries/session.ts` - TanStack Query session hooks
64+
- `routes/(app)/route.tsx` - Protected route guard
65+
- `components/auth/` - Auth UI components

0 commit comments

Comments
 (0)