diff --git a/__tests__/checkout-route.test.ts b/__tests__/checkout-route.test.ts
index 921a3cd..2a01b14 100644
--- a/__tests__/checkout-route.test.ts
+++ b/__tests__/checkout-route.test.ts
@@ -13,11 +13,11 @@ vi.mock('next/headers', () => ({
}));
vi.mock('@/lib/auth', () => ({
- auth: {
+ getAuth: () => ({
api: {
getSession: (...args: unknown[]) => mocks.getSession(...args),
},
- },
+ }),
}));
vi.mock('dodopayments', () => ({
diff --git a/migrations/0002_better_auth_tables.sql b/migrations/0002_better_auth_tables.sql
new file mode 100644
index 0000000..3079044
--- /dev/null
+++ b/migrations/0002_better_auth_tables.sql
@@ -0,0 +1,54 @@
+-- Better Auth's D1-backed OAuth/session tables.
+-- These singular model names intentionally remain separate from the app's
+-- existing plural `users` table.
+CREATE TABLE IF NOT EXISTS "user" (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ email TEXT NOT NULL UNIQUE,
+ emailVerified INTEGER NOT NULL DEFAULT 0,
+ image TEXT,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS "session" (
+ id TEXT PRIMARY KEY,
+ expiresAt TEXT NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL,
+ ipAddress TEXT,
+ userAgent TEXT,
+ userId TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_session_user_id ON "session" (userId);
+
+CREATE TABLE IF NOT EXISTS "account" (
+ id TEXT PRIMARY KEY,
+ accountId TEXT NOT NULL,
+ providerId TEXT NOT NULL,
+ userId TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+ accessToken TEXT,
+ refreshToken TEXT,
+ idToken TEXT,
+ accessTokenExpiresAt TEXT,
+ refreshTokenExpiresAt TEXT,
+ scope TEXT,
+ password TEXT,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_account_user_id ON "account" (userId);
+
+CREATE TABLE IF NOT EXISTS "verification" (
+ id TEXT PRIMARY KEY,
+ identifier TEXT NOT NULL,
+ value TEXT NOT NULL,
+ expiresAt TEXT NOT NULL,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_verification_identifier ON "verification" (identifier);
diff --git a/src/__tests__/user-menu.test.tsx b/src/__tests__/user-menu.test.tsx
new file mode 100644
index 0000000..a186341
--- /dev/null
+++ b/src/__tests__/user-menu.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { UserMenu } from '@/components/user-menu';
+
+const mockSignInSocial = vi.fn();
+
+vi.mock('@/lib/auth-client', () => ({
+ authClient: {
+ useSession: () => ({ data: null }),
+ signIn: {
+ social: (...args: unknown[]) => mockSignInSocial(...args),
+ },
+ },
+}));
+
+vi.mock('@/lib/foundry-monitoring', () => ({
+ captureAuthFailure: vi.fn(),
+}));
+
+beforeEach(() => {
+ mockSignInSocial.mockReset();
+ mockSignInSocial.mockResolvedValue({});
+});
+
+describe('UserMenu', () => {
+ it('returns Google sign-in to the dashboard', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Sign in' }));
+
+ expect(mockSignInSocial).toHaveBeenCalledWith({
+ provider: 'google',
+ callbackURL: '/dashboard',
+ });
+ });
+});
diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts
index 50be029..217a59b 100644
--- a/src/app/api/auth/[...all]/route.ts
+++ b/src/app/api/auth/[...all]/route.ts
@@ -1,5 +1,5 @@
import { toNextJsHandler } from 'better-auth/next-js';
-import { auth } from '@/lib/auth';
+import { getAuth } from '@/lib/auth';
-export const { GET, POST } = toNextJsHandler(auth.handler);
+export const { GET, POST } = toNextJsHandler((request) => getAuth().handler(request));
diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts
index e627953..892c6f3 100644
--- a/src/app/api/checkout/route.ts
+++ b/src/app/api/checkout/route.ts
@@ -3,7 +3,7 @@ import { headers } from 'next/headers';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
-import { auth } from '@/lib/auth';
+import { getAuth } from '@/lib/auth';
import { getProductId } from '@/lib/token-config';
let _client: DodoPayments | null = null;
@@ -61,7 +61,7 @@ function checkoutErrorResponse(error: unknown) {
}
export async function POST(request: NextRequest) {
- const session = await auth.api.getSession({ headers: await headers() });
+ const session = await getAuth().api.getSession({ headers: await headers() });
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
diff --git a/src/components/user-menu.tsx b/src/components/user-menu.tsx
index 59538fe..49f2cd0 100644
--- a/src/components/user-menu.tsx
+++ b/src/components/user-menu.tsx
@@ -25,7 +25,7 @@ export function UserMenu() {
if (!session?.user) {
function handleSignIn() {
authClient.signIn
- .social({ provider: 'google', callbackURL: '/' })
+ .social({ provider: 'google', callbackURL: '/dashboard' })
.then((result) => {
if (result?.error) {
captureAuthFailure({
diff --git a/src/lib/auth-utils.ts b/src/lib/auth-utils.ts
index 6d75d13..277b76d 100644
--- a/src/lib/auth-utils.ts
+++ b/src/lib/auth-utils.ts
@@ -1,10 +1,10 @@
import { headers } from 'next/headers';
-import { auth } from '@/lib/auth';
+import { getAuth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function getCurrentUserId(requestHeaders?: Headers): Promise {
- const session = await auth.api.getSession({ headers: requestHeaders ?? (await headers()) });
+ const session = await getAuth().api.getSession({ headers: requestHeaders ?? (await headers()) });
const user = session?.user;
if (!user?.id) return null;
diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts
new file mode 100644
index 0000000..029946c
--- /dev/null
+++ b/src/lib/auth.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildAuthOptions } from '@/lib/auth';
+
+describe('buildAuthOptions', () => {
+ it('registers Google from runtime credentials', () => {
+ const options = buildAuthOptions({
+ NODE_ENV: 'production',
+ BETTER_AUTH_SECRET: 'runtime-auth-secret',
+ BETTER_AUTH_URL: 'https://rolepatch.com',
+ GOOGLE_CLIENT_ID: 'runtime-client-id',
+ GOOGLE_CLIENT_SECRET: 'runtime-client-secret',
+ });
+
+ expect(options.secret).toBe('runtime-auth-secret');
+ expect(options.baseURL).toBe('https://rolepatch.com');
+ expect(options.socialProviders).toEqual({
+ google: {
+ clientId: 'runtime-client-id',
+ clientSecret: 'runtime-client-secret',
+ },
+ });
+ expect(options.trustedOrigins).toEqual(['https://rolepatch.com']);
+ });
+
+ it('does not invent production credentials when Google is not configured', () => {
+ const options = buildAuthOptions({
+ NODE_ENV: 'production',
+ BETTER_AUTH_URL: 'https://rolepatch.com',
+ });
+
+ expect(options.secret).toBeUndefined();
+ expect(options.socialProviders).toEqual({});
+ });
+});
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index efcfdcf..219da87 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,5 +1,6 @@
import { betterAuth } from 'better-auth';
import { createAdapter } from 'better-auth/adapters';
+import { getCloudflareContext } from '@opennextjs/cloudflare';
import { db } from '@/lib/db';
@@ -202,24 +203,61 @@ const d1Adapter = createAdapter({
}),
});
-const canUseLocalAuthSecret =
- process.env.NODE_ENV !== 'production' ||
- process.env.npm_lifecycle_event === 'build' ||
- process.env.NEXT_PHASE === 'phase-production-build';
-
-const authSecret =
- process.env.BETTER_AUTH_SECRET?.trim() ||
- (canUseLocalAuthSecret ? 'resume-tailor-local-development-secret-32-chars' : undefined);
-const googleClientId = process.env.GOOGLE_CLIENT_ID?.trim();
-const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET?.trim();
-
-export const auth = betterAuth({
- secret: authSecret,
- baseURL: process.env.BETTER_AUTH_URL,
- database: d1Adapter,
- socialProviders:
- googleClientId && googleClientSecret
- ? { google: { clientId: googleClientId, clientSecret: googleClientSecret } }
- : {},
- trustedOrigins: [process.env.BETTER_AUTH_URL || ''],
-});
+export type AuthRuntimeEnv = {
+ NODE_ENV?: string;
+ npm_lifecycle_event?: string;
+ NEXT_PHASE?: string;
+ BETTER_AUTH_SECRET?: string;
+ BETTER_AUTH_URL?: string;
+ GOOGLE_CLIENT_ID?: string;
+ GOOGLE_CLIENT_SECRET?: string;
+};
+
+/**
+ * OpenNext populates process.env from the Cloudflare request environment.
+ * Keep this construction request-lazy so Worker module evaluation cannot
+ * freeze production secrets as undefined before that initialization occurs.
+ */
+export function buildAuthOptions(env: AuthRuntimeEnv = process.env) {
+ const canUseLocalAuthSecret =
+ env.NODE_ENV !== 'production' ||
+ env.npm_lifecycle_event === 'build' ||
+ env.NEXT_PHASE === 'phase-production-build';
+ const authSecret =
+ env.BETTER_AUTH_SECRET?.trim() ||
+ (canUseLocalAuthSecret ? 'resume-tailor-local-development-secret-32-chars' : undefined);
+ const googleClientId = env.GOOGLE_CLIENT_ID?.trim();
+ const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim();
+
+ return {
+ secret: authSecret,
+ baseURL: env.BETTER_AUTH_URL?.trim() || undefined,
+ basePath: '/api/auth',
+ database: d1Adapter,
+ socialProviders:
+ googleClientId && googleClientSecret
+ ? { google: { clientId: googleClientId, clientSecret: googleClientSecret } }
+ : {},
+ trustedOrigins: env.BETTER_AUTH_URL ? [env.BETTER_AUTH_URL] : [],
+ };
+}
+
+function createAuth(env: AuthRuntimeEnv = process.env) {
+ return betterAuth(buildAuthOptions(env));
+}
+
+let authInstance: ReturnType | undefined;
+
+function getRuntimeAuthEnv(): AuthRuntimeEnv {
+ try {
+ const { env } = getCloudflareContext({ async: false });
+ return env as unknown as AuthRuntimeEnv;
+ } catch {
+ return process.env;
+ }
+}
+
+export function getAuth(): ReturnType {
+ authInstance ??= createAuth(getRuntimeAuthEnv());
+ return authInstance;
+}
diff --git a/src/lib/db-schema.sql b/src/lib/db-schema.sql
index 21c0248..3c39483 100644
--- a/src/lib/db-schema.sql
+++ b/src/lib/db-schema.sql
@@ -1,3 +1,57 @@
+-- Better Auth tables. Keep these separate from the app-level `users` table:
+-- Better Auth uses the singular model names for OAuth identities and sessions.
+CREATE TABLE IF NOT EXISTS "user" (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ email TEXT NOT NULL UNIQUE,
+ emailVerified INTEGER NOT NULL DEFAULT 0,
+ image TEXT,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS "session" (
+ id TEXT PRIMARY KEY,
+ expiresAt TEXT NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL,
+ ipAddress TEXT,
+ userAgent TEXT,
+ userId TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_session_user_id ON "session" (userId);
+
+CREATE TABLE IF NOT EXISTS "account" (
+ id TEXT PRIMARY KEY,
+ accountId TEXT NOT NULL,
+ providerId TEXT NOT NULL,
+ userId TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+ accessToken TEXT,
+ refreshToken TEXT,
+ idToken TEXT,
+ accessTokenExpiresAt TEXT,
+ refreshTokenExpiresAt TEXT,
+ scope TEXT,
+ password TEXT,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_account_user_id ON "account" (userId);
+
+CREATE TABLE IF NOT EXISTS "verification" (
+ id TEXT PRIMARY KEY,
+ identifier TEXT NOT NULL,
+ value TEXT NOT NULL,
+ expiresAt TEXT NOT NULL,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_verification_identifier ON "verification" (identifier);
+
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,