From daa1b9f46ad8b481545329f1503084bbece34138 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 16:38:09 +0800 Subject: [PATCH 1/2] feat(auth): replace Supabase Auth with self-hosted Better Auth Moves identity into the app's own Neon database, removing the last service that could pause underneath the product. Email/password with mandatory verification plus Google OAuth, sessions via cookies rather than bearer tokens. Also fixes a cross-database defect the Neon migration introduced: getUserTier read `profiles` through the retired Supabase client while quota usage came from Neon, so every user silently resolved to FREE. Tier now lives on the Better Auth user table and is read via Prisma. Verified end to end against Neon: signup, pre-verification sign-in rejected 403, verification, sign-in, and a protected route returning 401 without a cookie and 200 with one. Refs #6 --- backend/package.json | 1 + backend/src/app.ts | 3 + backend/src/config/auth.ts | 79 +++++ backend/src/config/env.ts | 4 + backend/src/middleware/auth.ts | 25 +- .../services/__tests__/userService.test.ts | 32 +- backend/src/services/emailService.ts | 27 ++ backend/src/services/userService.ts | 15 +- frontend/package.json | 1 + frontend/src/api/client.ts | 13 +- .../components/auth/GoogleSignInButton.tsx | 4 +- .../chat/__tests__/ChatLauncher.test.tsx | 12 +- frontend/src/hooks/__tests__/useAuth.test.tsx | 173 ++++++++++ frontend/src/hooks/__tests__/useChat.test.tsx | 19 +- frontend/src/hooks/useAuth.tsx | 92 +++--- frontend/src/hooks/useChat.ts | 20 +- frontend/src/hooks/useLocale.tsx | 17 +- frontend/src/lib/auth-client.ts | 26 ++ frontend/src/lib/supabase.ts | 34 -- .../src/pages/__tests__/SignInPage.test.tsx | 4 +- .../src/pages/__tests__/SignUpPage.test.tsx | 2 +- frontend/vite.config.ts | 6 +- pnpm-lock.yaml | 297 ++++++++++++++++++ .../migration.sql | 82 +++++ .../migration.sql | 2 + prisma/schema.prisma | 73 +++++ 26 files changed, 853 insertions(+), 210 deletions(-) create mode 100644 backend/src/config/auth.ts create mode 100644 backend/src/services/emailService.ts create mode 100644 frontend/src/hooks/__tests__/useAuth.test.tsx create mode 100644 frontend/src/lib/auth-client.ts delete mode 100644 frontend/src/lib/supabase.ts create mode 100644 prisma/migrations/20260730081714_add_better_auth_tables/migration.sql create mode 100644 prisma/migrations/20260730083243_add_user_locale/migration.sql diff --git a/backend/package.json b/backend/package.json index 4aeebd1..20bc1d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ "@prisma/client": "^6.6.0", "@shared/types": "workspace:*", "@supabase/supabase-js": "^2.49.4", + "better-auth": "^1.6.25", "compression": "^1.8.1", "cors": "^2.8.5", "dotenv": "^16.5.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 68100d8..b1af3d0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -12,6 +12,8 @@ import { tariffRouter } from './routes/tariff.js' import { errorHandler } from './middleware/errorHandler.js' import { requestLogger } from './middleware/requestLogger.js' import { env } from './config/env.js' +import { toNodeHandler } from 'better-auth/node' +import { auth } from './config/auth.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -64,6 +66,7 @@ app.use( } }) ) +app.all('/api/auth/*splat', toNodeHandler(auth)) app.use(express.json()) app.use(requestLogger) diff --git a/backend/src/config/auth.ts b/backend/src/config/auth.ts new file mode 100644 index 0000000..856922f --- /dev/null +++ b/backend/src/config/auth.ts @@ -0,0 +1,79 @@ +/** + * Better Auth server configuration — the single source of identity for the app. + * + * Replaces Supabase Auth. Sessions and accounts live in the same Neon database + * as application data, so there is no external identity provider that can pause + * or change terms underneath us. + */ + +import { betterAuth } from 'better-auth' +import { prismaAdapter } from 'better-auth/adapters/prisma' +import { env } from './env.js' +import { prisma } from './prisma.js' +import { sendVerificationEmail, sendPasswordResetEmail } from '../services/emailService.js' + +export const auth = betterAuth({ + baseURL: env.BETTER_AUTH_URL, + secret: env.BETTER_AUTH_SECRET, + database: prismaAdapter(prisma, { provider: 'postgresql' }), + trustedOrigins: [env.FRONTEND_URL], + + emailAndPassword: { + enabled: true, + // Matches the retired Supabase `enable_confirmations = true`: a new account + // cannot sign in until its address is verified. + requireEmailVerification: true, + autoSignIn: false, + sendResetPassword: async ({ user, url }) => { + await sendPasswordResetEmail(user.email, url) + } + }, + + emailVerification: { + sendOnSignUp: true, + sendOnSignIn: true, + sendVerificationEmail: async ({ user, url }) => { + await sendVerificationEmail(user.email, url) + } + }, + + socialProviders: { + google: { + clientId: env.GOOGLE_OAUTH_CLIENT_ID, + clientSecret: env.GOOGLE_OAUTH_SECRET + } + }, + + account: { + // Google access/refresh tokens are stored in the `account` table; encrypt + // them at rest rather than accepting the plaintext default. + encryptOAuthTokens: true, + accountLinking: { + // Mirrors the retired `enable_manual_linking = false`: signing in with + // Google using an address that already has a password account lands on + // that same account instead of creating a duplicate. + enabled: true, + trustedProviders: ['google'] + } + }, + + user: { + additionalFields: { + // Subscription tier drives daily project quota. Server-owned: `input: false` + // stops a client from promoting itself by posting a tier on signup. + tier: { + type: 'string', + required: false, + defaultValue: 'FREE', + input: false + }, + // UI language, persisted server-side so the choice follows the user + // across devices. Client-writable, unlike tier. + locale: { + type: 'string', + required: false, + input: true + } + } + } +}) diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 75f4913..c9f2ea1 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -31,6 +31,10 @@ const envSchema = z R2_ACCESS_KEY_ID: z.string().min(1), R2_SECRET_ACCESS_KEY: z.string().min(1), R2_BUCKET: z.string().min(1), + BETTER_AUTH_SECRET: z.string().min(32), + BETTER_AUTH_URL: z.string().url(), + GOOGLE_OAUTH_CLIENT_ID: z.string().min(1), + GOOGLE_OAUTH_SECRET: z.string().min(1), FRONTEND_URL: z.string().url().optional().default('http://localhost:5173'), PDF_TOKEN_SECRET: z.string().min(32), GEMINI_API_KEY: z.preprocess((val) => (val === '' ? undefined : val), z.string().min(1).optional()), diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index f01896c..0d723cb 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,12 +1,13 @@ /** - * Supabase bearer-token authentication middleware. + * Better Auth session authentication middleware. * - * Verifies API requests against Supabase Auth and attaches the authenticated + * Verifies API requests against Better Auth and attaches the authenticated * user identity to Express requests for downstream route ownership checks. */ import type { Request, Response, NextFunction } from 'express' -import { supabase } from '../config/supabase.js' +import { fromNodeHeaders } from 'better-auth/node' +import { auth } from '../config/auth.js' declare global { namespace Express { @@ -18,31 +19,23 @@ declare global { } /** - * Verifies the `Authorization: Bearer ` session and stores the Supabase + * Verifies the Better Auth session and stores the authenticated * user id/email on `req.user`. * - * @param req - Incoming request carrying a Supabase access token + * @param req - Incoming request carrying Better Auth session cookies * @param res - Response used for unauthorised JSON failures * @param next - Continuation called after successful authentication */ export async function requireAuth(req: Request, res: Response, next: NextFunction) { - const authHeader = req.headers.authorization - if (!authHeader?.startsWith('Bearer ')) { - console.warn(`[Auth] Missing bearer token for ${req.method} ${req.originalUrl}`) - res.status(401).json({ error: 'Unauthorized' }) - return - } - - const token = authHeader.slice(7) - const { data, error } = await supabase.auth.getUser(token) + const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) }) - if (error || !data.user) { + if (!session?.user) { console.warn(`[Auth] Invalid session for ${req.method} ${req.originalUrl}`) res.status(401).json({ error: 'Unauthorized' }) return } - req.user = { id: data.user.id, email: data.user.email ?? '' } + req.user = { id: session.user.id, email: session.user.email ?? '' } console.info(`[Auth] user=${req.user.id} ${req.method} ${req.originalUrl}`) next() } diff --git a/backend/src/services/__tests__/userService.test.ts b/backend/src/services/__tests__/userService.test.ts index 78c2e86..b9fa3ed 100644 --- a/backend/src/services/__tests__/userService.test.ts +++ b/backend/src/services/__tests__/userService.test.ts @@ -1,24 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { count, single, eq, select, from } = vi.hoisted(() => { - const single = vi.fn() - const eq = vi.fn(() => ({ single })) - const select = vi.fn(() => ({ eq })) - const from = vi.fn(() => ({ select })) +const { count, findUnique } = vi.hoisted(() => { + const findUnique = vi.fn() const count = vi.fn() - return { count, single, eq, select, from } + return { count, findUnique } }) vi.mock('../../config/prisma.js', () => ({ prisma: { + user: { findUnique }, projectQuotaUsage: { count } } })) -vi.mock('../../config/supabase.js', () => ({ - supabase: { from } -})) - import { getQuotaSummary, startOfUtcDay, nextUtcMidnight } from '../userService.js' describe('userService date helpers', () => { @@ -40,15 +34,12 @@ describe('userService date helpers', () => { describe('getQuotaSummary', () => { beforeEach(() => { - single.mockReset() + findUnique.mockReset() count.mockReset() - from.mockClear() - select.mockClear() - eq.mockClear() }) it('returns FREE tier quota with used count and reset timestamp', async () => { - single.mockResolvedValue({ data: { tier: 'FREE' }, error: null }) + findUnique.mockResolvedValue({ tier: 'FREE' }) count.mockResolvedValue(3) const summary = await getQuotaSummary('user_1', new Date('2026-04-17T10:00:00.000Z')) @@ -62,10 +53,11 @@ describe('getQuotaSummary', () => { expect(count).toHaveBeenCalledWith({ where: { userId: 'user_1', createdAt: { gte: new Date('2026-04-17T00:00:00.000Z') } } }) + expect(findUnique).toHaveBeenCalledWith({ where: { id: 'user_1' }, select: { tier: true } }) }) it('blocks FREE user at 5/5 used (caller enforces)', async () => { - single.mockResolvedValue({ data: { tier: 'FREE' }, error: null }) + findUnique.mockResolvedValue({ tier: 'FREE' }) count.mockResolvedValue(5) const summary = await getQuotaSummary('user_1', new Date('2026-04-17T10:00:00.000Z')) @@ -75,7 +67,7 @@ describe('getQuotaSummary', () => { }) it('returns PRO tier with 20-project cap', async () => { - single.mockResolvedValue({ data: { tier: 'PRO' }, error: null }) + findUnique.mockResolvedValue({ tier: 'PRO' }) count.mockResolvedValue(12) const summary = await getQuotaSummary('user_2', new Date('2026-04-17T10:00:00.000Z')) @@ -86,7 +78,7 @@ describe('getQuotaSummary', () => { }) it('returns ENTERPRISE tier with null (unlimited) limit', async () => { - single.mockResolvedValue({ data: { tier: 'ENTERPRISE' }, error: null }) + findUnique.mockResolvedValue({ tier: 'ENTERPRISE' }) count.mockResolvedValue(99) const summary = await getQuotaSummary('user_3', new Date('2026-04-17T10:00:00.000Z')) @@ -97,7 +89,7 @@ describe('getQuotaSummary', () => { }) it('falls back to FREE when profile row is missing', async () => { - single.mockResolvedValue({ data: null, error: { message: 'not found' } }) + findUnique.mockResolvedValue(null) count.mockResolvedValue(0) const summary = await getQuotaSummary('ghost', new Date('2026-04-17T10:00:00.000Z')) @@ -107,7 +99,7 @@ describe('getQuotaSummary', () => { }) it('resets used count after UTC midnight (injectable clock)', async () => { - single.mockResolvedValue({ data: { tier: 'FREE' }, error: null }) + findUnique.mockResolvedValue({ tier: 'FREE' }) count.mockResolvedValueOnce(5).mockResolvedValueOnce(0) const before = await getQuotaSummary('user_1', new Date('2026-04-17T23:59:00.000Z')) diff --git a/backend/src/services/emailService.ts b/backend/src/services/emailService.ts new file mode 100644 index 0000000..edcd3e2 --- /dev/null +++ b/backend/src/services/emailService.ts @@ -0,0 +1,27 @@ +/** + * Transactional email dispatch. + * + * Placeholder implementation: issue #7 replaces these bodies with direct Resend + * calls and the branded templates ported out of `supabase/templates/`. Until + * then the links are logged so local sign-up flows remain completable. + */ + +/** + * Sends an address-verification link to a newly registered user. + * + * @param email - Recipient address + * @param url - Better Auth verification link + */ +export async function sendVerificationEmail(email: string, url: string): Promise { + console.info(`[Email] verification for ${email}: ${url}`) +} + +/** + * Sends a password-reset link. + * + * @param email - Recipient address + * @param url - Better Auth password-reset link + */ +export async function sendPasswordResetEmail(email: string, url: string): Promise { + console.info(`[Email] password reset for ${email}: ${url}`) +} diff --git a/backend/src/services/userService.ts b/backend/src/services/userService.ts index 539bfcd..45a8d0f 100644 --- a/backend/src/services/userService.ts +++ b/backend/src/services/userService.ts @@ -5,7 +5,6 @@ * UTC reset windows. */ -import { supabase } from '../config/supabase.js' import { prisma } from '../config/prisma.js' import { TIER_DAILY_LIMITS, type UserTier, type QuotaSummary } from '@shared/types' @@ -34,18 +33,18 @@ export function nextUtcMidnight(now: Date = new Date()): Date { } /** - * Reads the user's subscription tier from Supabase profiles. + * Reads the user's subscription tier from the application user record. * - * @param userId - Authenticated user id matching the profile row - * @returns User tier, defaulting to `FREE` when the profile is missing + * @param userId - Authenticated user id matching the user record + * @returns User tier, defaulting to `FREE` when the user is missing */ export async function getUserTier(userId: string): Promise { - const { data, error } = await supabase.from('profiles').select('tier').eq('id', userId).single() - if (error || !data) { - console.warn(`[UserTier] profile missing for user=${userId}, defaulting to FREE`, error?.message ?? '') + const user = await prisma.user.findUnique({ where: { id: userId }, select: { tier: true } }) + if (!user) { + console.warn(`[UserTier] profile missing for user=${userId}, defaulting to FREE`) return 'FREE' } - return data.tier as UserTier + return user.tier } /** diff --git a/frontend/package.json b/frontend/package.json index 9795754..402666d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@shared/types": "workspace:*", "@supabase/supabase-js": "^2.98.0", "@tanstack/react-query": "^5.75.5", + "better-auth": "^1.6.25", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.38.0", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 950c752..139d0b9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,7 +2,7 @@ * Authenticated API client. * * Every backend call goes through `apiFetch`, which: - * - Pulls the current Supabase session and attaches a `Bearer` token. + * - Includes the browser's session cookie. * - JSON-encodes the body and sets the right content-type. * - Logs request / response info in dev mode for easier debugging. * - Throws a typed `ApiError` on non-2xx responses so React Query and @@ -12,8 +12,6 @@ * rewritten by Vercel in production). */ -import { getSupabase } from '@/lib/supabase' - /** * Error thrown by `apiFetch` for any non-2xx response. Preserves the HTTP * status code so callers can branch on 401 (sign-out), 429 (quota), etc. @@ -37,10 +35,6 @@ export class ApiError extends Error { * @throws `ApiError` if the response status is not 2xx */ export async function apiFetch(path: string, options?: RequestInit): Promise { - const supabase = getSupabase() - const { - data: { session } - } = await supabase.auth.getSession() const method = options?.method ?? 'GET' const headers: Record = { @@ -48,16 +42,13 @@ export async function apiFetch(path: string, options?: RequestInit): Promise< ...((options?.headers as Record) ?? {}) } - if (session?.access_token) { - headers['Authorization'] = `Bearer ${session.access_token}` - } - if (import.meta.env.DEV) { console.info(`[API] ${method} ${path}`) } const response = await fetch(`/api${path}`, { ...options, + credentials: 'include', headers }) diff --git a/frontend/src/components/auth/GoogleSignInButton.tsx b/frontend/src/components/auth/GoogleSignInButton.tsx index 9f66b96..2fc726e 100644 --- a/frontend/src/components/auth/GoogleSignInButton.tsx +++ b/frontend/src/components/auth/GoogleSignInButton.tsx @@ -1,5 +1,5 @@ /** - * Supabase Google OAuth entry point for sign-in and sign-up screens. + * Google OAuth entry point for sign-in and sign-up screens. * Used anywhere the auth flow offers one-click provider login instead of email credentials. */ @@ -16,7 +16,7 @@ interface GoogleSignInButtonProps { } /** - * Renders a branded Google sign-in action and invokes Supabase OAuth through the auth hook. + * Renders a branded Google sign-in action through the auth hook. * @param props - Optional label override and error callback for surfacing OAuth launch failures. */ export function GoogleSignInButton({ label = 'Continue with Google', onError }: GoogleSignInButtonProps) { diff --git a/frontend/src/components/chat/__tests__/ChatLauncher.test.tsx b/frontend/src/components/chat/__tests__/ChatLauncher.test.tsx index c4b48d5..8d5e2e1 100644 --- a/frontend/src/components/chat/__tests__/ChatLauncher.test.tsx +++ b/frontend/src/components/chat/__tests__/ChatLauncher.test.tsx @@ -6,7 +6,7 @@ // lifecycle and page-aware mounting. import React from 'react' -import { fireEvent, render, screen, act } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi, beforeEach } from 'vitest' vi.mock('react-i18next', () => ({ @@ -20,14 +20,6 @@ vi.mock('@/hooks/useAuth', () => ({ useAuth: () => ({ user: { id: 'user-1' } }) })) -vi.mock('@/lib/supabase', () => ({ - getSupabase: () => ({ - auth: { - getSession: () => Promise.resolve({ data: { session: { access_token: 'tok' } } }) - } - }) -})) - vi.mock('../ChatPanel', () => ({ ChatPanel: ({ projectId, page }: { projectId: string; page: string }) => (
chat-panel
@@ -35,7 +27,7 @@ vi.mock('../ChatPanel', () => ({ })) import { ChatLauncher } from '../ChatLauncher' -import { ChatProvider, ChatContext } from '../ChatProvider' +import { ChatProvider } from '../ChatProvider' function renderLauncher(page: 'workbench' | 'analysis', projectId = 'project-1') { return render( diff --git a/frontend/src/hooks/__tests__/useAuth.test.tsx b/frontend/src/hooks/__tests__/useAuth.test.tsx new file mode 100644 index 0000000..67ba1e1 --- /dev/null +++ b/frontend/src/hooks/__tests__/useAuth.test.tsx @@ -0,0 +1,173 @@ +import React, { useState } from 'react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { notifyErrorMock, signInEmailMock, signInSocialMock, signOutMock, signUpEmailMock, useSessionMock } = vi.hoisted( + () => ({ + notifyErrorMock: vi.fn(), + signInEmailMock: vi.fn(), + signInSocialMock: vi.fn(), + signOutMock: vi.fn(), + signUpEmailMock: vi.fn(), + useSessionMock: vi.fn() + }) +) + +vi.mock('@/components/ui/toastConfig', () => ({ + notify: { error: notifyErrorMock } +})) + +vi.mock('@/lib/auth-client', () => ({ + authClient: { + signIn: { + email: (...args: unknown[]) => signInEmailMock(...args), + social: (...args: unknown[]) => signInSocialMock(...args) + }, + signOut: (...args: unknown[]) => signOutMock(...args), + signUp: { + email: (...args: unknown[]) => signUpEmailMock(...args) + }, + useSession: (...args: unknown[]) => useSessionMock(...args) + } +})) + +import { AuthProvider, useAuth } from '../useAuth' + +type SessionState = { + data: { + user: { id: string; email: string } + session: { id: string; userId: string } + } | null + isPending: boolean +} + +let sessionState: SessionState + +function AuthProbe() { + const { loading, signIn, signInWithGoogle, signOut, signUp, user } = useAuth() + const [result, setResult] = useState('') + + return ( + <> + {loading ? 'loading' : 'ready'} + {user?.email ?? 'none'} + {result} + + + + + + ) +} + +function renderAuth(queryClient = new QueryClient()) { + return { + queryClient, + ...render( + + + + + + ) + } +} + +describe('AuthProvider', () => { + beforeEach(() => { + notifyErrorMock.mockReset() + signInEmailMock.mockReset() + signInSocialMock.mockReset() + signOutMock.mockReset() + signUpEmailMock.mockReset() + sessionState = { + data: { + user: { id: 'user-a', email: 'a@example.com' }, + session: { id: 'session-a', userId: 'user-a' } + }, + isPending: false + } + useSessionMock.mockImplementation(() => sessionState) + signInEmailMock.mockResolvedValue({ error: null }) + signInSocialMock.mockResolvedValue({ error: null }) + signOutMock.mockResolvedValue({ error: null }) + signUpEmailMock.mockResolvedValue({ error: null }) + window.history.replaceState({}, '', '/') + }) + + it('keeps routes in the loading state until the initial session resolves', () => { + sessionState = { data: null, isPending: true } + renderAuth() + + expect(screen.getByTestId('loading').textContent).toBe('loading') + }) + + it('clears cached projects when an authenticated session is lost', async () => { + const { queryClient, rerender } = renderAuth() + queryClient.setQueryData(['projects'], [{ id: 'project-a' }]) + + sessionState = { data: null, isPending: false } + rerender( + + + + + + ) + + await waitFor(() => expect(queryClient.getQueryData(['projects'])).toBeUndefined()) + }) + + it('clears cached projects when the user signs out', async () => { + const { queryClient } = renderAuth() + queryClient.setQueryData(['projects'], [{ id: 'project-a' }]) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Sign out' })) + }) + + expect(signOutMock).toHaveBeenCalledOnce() + expect(queryClient.getQueryData(['projects'])).toBeUndefined() + }) + + it('maps email and Google actions to the Better Auth client', async () => { + renderAuth() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Sign in' })) + fireEvent.click(screen.getByRole('button', { name: 'Sign up' })) + fireEvent.click(screen.getByRole('button', { name: 'Google' })) + }) + + expect(signInEmailMock).toHaveBeenCalledWith({ email: 'member@example.com', password: 'password' }) + expect(signUpEmailMock).toHaveBeenCalledWith({ + email: 'member@example.com', + name: 'member@example.com', + password: 'password' + }) + expect(signInSocialMock).toHaveBeenCalledWith({ provider: 'google', callbackURL: '/dashboard' }) + }) + + it('surfaces and removes OAuth callback errors', async () => { + window.history.replaceState({}, '', '/sign-in?error=access_denied&error_description=Provider%20denied') + renderAuth() + + await waitFor(() => expect(notifyErrorMock).toHaveBeenCalledWith('Provider denied')) + expect(window.location.search).toBe('') + }) +}) diff --git a/frontend/src/hooks/__tests__/useChat.test.tsx b/frontend/src/hooks/__tests__/useChat.test.tsx index 49564de..71d3fc7 100644 --- a/frontend/src/hooks/__tests__/useChat.test.tsx +++ b/frontend/src/hooks/__tests__/useChat.test.tsx @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChatProvider } from '@/components/chat/ChatProvider' import { useChat } from '../useChat' -const getSessionMock = vi.fn() let idCounter = 0 function installFetchMock(fetchMock: ReturnType) { @@ -20,14 +19,6 @@ function installFetchMock(fetchMock: ReturnType) { }) } -vi.mock('@/lib/supabase', () => ({ - getSupabase: () => ({ - auth: { - getSession: (...args: unknown[]) => getSessionMock(...args) - } - }) -})) - vi.mock('@/hooks/useAuth', () => ({ useAuth: () => ({ user: { id: 'user-1' } @@ -73,14 +64,6 @@ describe('useChat', () => { beforeEach(() => { vi.restoreAllMocks() idCounter = 0 - getSessionMock.mockReset() - getSessionMock.mockResolvedValue({ - data: { - session: { - access_token: 'test-token' - } - } - }) vi.stubGlobal('crypto', { randomUUID: () => `message-${++idCounter}` }) @@ -111,9 +94,9 @@ describe('useChat', () => { expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ - Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }), + credentials: 'include', signal: expect.any(AbortSignal) }) ) diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index e6fbf45..c8f04ce 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -1,36 +1,37 @@ /** - * Auth provider + hook backed by Supabase Auth. + * Auth provider + hook backed by Better Auth. * - * Wraps `supabase.auth` and exposes the current user/session, loading state, + * Wraps `authClient` and exposes the current user/session, loading state, * and helpers for email-password and Google OAuth sign-in/out. Also handles * two cross-cutting concerns: - * - Surfaces OAuth callback errors that come back in the URL (Supabase - * implicit flow puts them in the hash, some flows put them in the query). - * Without this the user lands silently with no idea why sign-in failed. + * - Surfaces OAuth callback errors that come back in the URL. Without this + * the user lands silently with no idea why sign-in failed. * - Wipes the React Query cache on sign-out so a subsequent sign-in cannot * briefly render the previous user's cached data. */ -import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react' -import type { User, Session, AuthError } from '@supabase/supabase-js' +import { createContext, useCallback, useContext, useEffect, type ReactNode } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { getSupabase } from '@/lib/supabase' +import { authClient } from '@/lib/auth-client' import { notify } from '@/components/ui/toastConfig' +type AuthSessionData = typeof authClient.$Infer.Session +type AuthError = { message: string } + /** * Value exposed by `useAuth`. * - * - `user` — current Supabase user, or `null` if signed out - * - `session` — current session (includes access token), or `null` - * - `loading` — `true` while the initial `getSession()` is pending + * - `user` — current user, or `null` if signed out + * - `session` — current session, or `null` + * - `loading` — `true` while the initial session request is pending * - `signIn` — email-password sign-in; returns `{ error }` * - `signUp` — email-password sign-up; returns `{ error }` * - `signInWithGoogle` — Google OAuth sign-in; returns `{ error }` * - `signOut` — sign out and clear the React Query cache */ type AuthContextValue = { - user: User | null - session: Session | null + user: AuthSessionData['user'] | null + session: AuthSessionData['session'] | null loading: boolean signIn: (email: string, password: string) => Promise<{ error: AuthError | null }> signUp: (email: string, password: string) => Promise<{ error: AuthError | null }> @@ -40,22 +41,25 @@ type AuthContextValue = { const AuthContext = createContext(undefined) +function toAuthError(error: unknown): AuthError | null { + if (!error) return null + if (typeof error === 'object' && 'message' in error && typeof error.message === 'string') { + return { message: error.message } + } + return { message: 'Authentication failed' } +} + /** * Wraps the React tree with auth context. Mount once near the root, above any * component that calls `useAuth`. */ export function AuthProvider({ children }: { children: ReactNode }) { - const supabase = getSupabase() const queryClient = useQueryClient() - const [user, setUser] = useState(null) - const [session, setSession] = useState(null) - const [loading, setLoading] = useState(true) - - // Surface OAuth callback errors. Supabase implicit-flow failures (e.g. identity already - // exists, access denied, server_error) come back in the URL hash; some flows put them in - // the query string instead. Without this, the user lands silently on /dashboard or /sign-in - // with no idea why their sign-in failed. Strip the params after toasting so a page refresh - // doesn't re-fire. + const { data, isPending } = authClient.useSession() + const user = data?.user ?? null + const session = data?.session ?? null + + // Strip callback errors after toasting so a page refresh does not re-fire them. useEffect(() => { if (typeof window === 'undefined') return const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : '' @@ -76,53 +80,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []) useEffect(() => { - supabase.auth.getSession().then(({ data: { session } }) => { - setSession(session) - setUser(session?.user ?? null) - setLoading(false) - }) - - const { - data: { subscription } - } = supabase.auth.onAuthStateChange((event, session) => { - setSession(session) - setUser(session?.user ?? null) - // Wipe the React Query cache when the user signs out (or the session expires) - // so a subsequent sign-in cannot momentarily render the previous user's - // cached projects/quota/analyses while the refetch is in flight. - if (event === 'SIGNED_OUT' || !session) { - queryClient.clear() - } - }) - - return () => subscription.unsubscribe() - }, [queryClient]) + if (!isPending && !session) queryClient.clear() + }, [isPending, queryClient, session]) const signIn = useCallback(async (email: string, password: string) => { - const { error } = await supabase.auth.signInWithPassword({ email, password }) - return { error } + const { error } = await authClient.signIn.email({ email, password }) + return { error: toAuthError(error) } }, []) const signUp = useCallback(async (email: string, password: string) => { - const { error } = await supabase.auth.signUp({ email, password }) - return { error } + const { error } = await authClient.signUp.email({ email, name: email, password }) + return { error: toAuthError(error) } }, []) const signInWithGoogle = useCallback(async () => { - const { error } = await supabase.auth.signInWithOAuth({ - provider: 'google', - options: { redirectTo: `${window.location.origin}/dashboard` } - }) - return { error } + const { error } = await authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' }) + return { error: toAuthError(error) } }, []) const signOut = useCallback(async () => { - await supabase.auth.signOut() + await authClient.signOut() queryClient.clear() }, [queryClient]) return ( - + {children} ) diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts index f9b4b2a..75622fd 100644 --- a/frontend/src/hooks/useChat.ts +++ b/frontend/src/hooks/useChat.ts @@ -1,7 +1,6 @@ import { useCallback, useContext, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { CHAT_SEND_COOLDOWN_MS, ChatContext, type ChatMessage } from '@/components/chat/ChatProvider' -import { getSupabase } from '@/lib/supabase' import type { AnalysisResultsDto, LayoutPreferences, PanelEdit, StoredAnalysisConfigDto } from '@shared/types' /** How many follow-up chips to render per model bubble, sampled from the page-specific pool. */ @@ -73,20 +72,6 @@ function drainSseBuffer(buffer: string): { events: ChatEvent[]; remaining: strin return { events, remaining } } -/** Builds JSON + Bearer-auth headers from the current Supabase session, if any. */ -async function buildAuthHeaders(): Promise> { - const supabase = getSupabase() - const { - data: { session } - } = await supabase.auth.getSession() - - const headers: Record = { 'Content-Type': 'application/json' } - if (session?.access_token) { - headers.Authorization = `Bearer ${session.access_token}` - } - return headers -} - type UseChatReturn = { messages: ChatMessage[] isStreaming: boolean @@ -214,8 +199,6 @@ export function useChat( const followupPool = t(followupPoolKey, { returnObjects: true, defaultValue: [] }) as unknown as string[] try { - const headers = await buildAuthHeaders() - const liveStateSnapshot = liveStateProviderRef.current?.() const requestBody: Record = { message, @@ -229,7 +212,8 @@ export function useChat( const response = await fetch(`/api/projects/${projectId}/chat`, { method: 'POST', - headers, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', signal: controller.signal, body: JSON.stringify(requestBody) }) diff --git a/frontend/src/hooks/useLocale.tsx b/frontend/src/hooks/useLocale.tsx index 0b5d324..832721d 100644 --- a/frontend/src/hooks/useLocale.tsx +++ b/frontend/src/hooks/useLocale.tsx @@ -3,8 +3,8 @@ * * Reads the initial locale from (in priority order): the `?locale=` query * param, localStorage, then the default. Persists every change to - * localStorage so the next page load remembers it, and to the Supabase user - * metadata when signed in so the choice survives across devices. + * localStorage so the next page load remembers it, and to the signed-in user's + * server-side `locale` field so the choice survives across devices. * * Syncs the locale to i18next and to the `` attribute so * screen-readers and CSS `:lang(...)` selectors work correctly. @@ -13,7 +13,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react' import i18n, { DEFAULT_LOCALE, isSupportedLocale, LOCALE_STORAGE_KEY, type SupportedLocale } from '@/lib/i18n' import { useAuth } from '@/hooks/useAuth' -import { getSupabase } from '@/lib/supabase' +import { authClient } from '@/lib/auth-client' /** Value exposed by `useLocale`. */ type LocaleContextValue = { @@ -66,8 +66,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) { } if (lastSyncedUserId.current === user.id) return lastSyncedUserId.current = user.id - const meta = (user.user_metadata ?? {}) as Record - const remote = meta.locale as string | null | undefined + const remote = user.locale if (isSupportedLocale(remote) && remote !== locale) { setLocaleState(remote) window.localStorage.setItem(LOCALE_STORAGE_KEY, remote) @@ -81,11 +80,9 @@ export function LocaleProvider({ children }: { children: ReactNode }) { window.localStorage.setItem(LOCALE_STORAGE_KEY, next) } if (user) { - getSupabase() - .auth.updateUser({ data: { locale: next } }) - .catch(() => { - /* non-fatal */ - }) + authClient.updateUser({ locale: next }).catch(() => { + /* non-fatal */ + }) } }, [user] diff --git a/frontend/src/lib/auth-client.ts b/frontend/src/lib/auth-client.ts new file mode 100644 index 0000000..c049567 --- /dev/null +++ b/frontend/src/lib/auth-client.ts @@ -0,0 +1,26 @@ +/** + * Better Auth client singleton. + * + * Uses the same-origin `/api/auth` mount in development and production, so + * browser session cookies work without a separate base URL. + * + * `inferAdditionalFields` is declared with a literal schema rather than the + * server's `typeof auth`, which would drag backend types across the workspace + * boundary. Keep this in sync with `user.additionalFields` in + * `backend/src/config/auth.ts` — `tier` is server-owned and not client-writable. + */ + +import { createAuthClient } from 'better-auth/react' +import { inferAdditionalFields } from 'better-auth/client/plugins' + +export const authClient = createAuthClient({ + basePath: '/api/auth', + plugins: [ + inferAdditionalFields({ + user: { + tier: { type: 'string', required: false, input: false }, + locale: { type: 'string', required: false } + } + }) + ] +}) diff --git a/frontend/src/lib/supabase.ts b/frontend/src/lib/supabase.ts deleted file mode 100644 index 9be2a59..0000000 --- a/frontend/src/lib/supabase.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Browser-side Supabase client factory. - * - * `createClient` is lazy because import.meta.env is only safe to read inside - * the Vite-built bundle, and the singleton pattern avoids creating multiple - * Realtime websocket connections from React's strict-mode double-mount. - */ - -import { createClient } from '@supabase/supabase-js' - -let supabaseClient: ReturnType | null = null - -/** - * Returns the singleton Supabase client, creating it on first call. - * - * @throws If `VITE_SUPABASE_URL` or `VITE_SUPABASE_ANON_KEY` is missing — - * the app cannot run without these and failing loudly is better than - * producing confusing auth errors later. - */ -export function getSupabase() { - if (supabaseClient) { - return supabaseClient - } - - const supabaseUrl = import.meta.env.VITE_SUPABASE_URL - const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY - - if (!supabaseUrl || !supabaseAnonKey) { - throw new Error('Missing SUPABASE_URL or SUPABASE_ANON_KEY — set them in the root .env') - } - - supabaseClient = createClient(supabaseUrl, supabaseAnonKey) - return supabaseClient -} diff --git a/frontend/src/pages/__tests__/SignInPage.test.tsx b/frontend/src/pages/__tests__/SignInPage.test.tsx index 2d343c7..5239726 100644 --- a/frontend/src/pages/__tests__/SignInPage.test.tsx +++ b/frontend/src/pages/__tests__/SignInPage.test.tsx @@ -1,6 +1,6 @@ // Tests for §5.2.1.5 Authentication > SignInPage (TCNO prefix SI) // Covers frontend/src/pages/SignInPage.tsx surface-level form behaviour only. -// Auth scaffolding (Supabase Auth client internals) excluded per Chapter 4 §4.6. +// Auth client internals are excluded per Chapter 4 §4.6. import React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -115,7 +115,7 @@ describe('SignInPage', () => { }) // SI-05 - it('renders the Supabase error string in a destructive banner on auth failure', async () => { + it('renders the auth error string in a destructive banner on auth failure', async () => { setAuth() signInMock.mockResolvedValue({ error: { message: 'Invalid login credentials' } }) diff --git a/frontend/src/pages/__tests__/SignUpPage.test.tsx b/frontend/src/pages/__tests__/SignUpPage.test.tsx index fb12558..dd3150d 100644 --- a/frontend/src/pages/__tests__/SignUpPage.test.tsx +++ b/frontend/src/pages/__tests__/SignUpPage.test.tsx @@ -76,7 +76,7 @@ describe('SignUpPage', () => { }) // SU-04 - it('renders the Supabase error string when signUp returns an error', async () => { + it('renders the auth error string when signUp returns an error', async () => { setAuth() signUpMock.mockResolvedValue({ error: { message: 'User already registered' } }) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7986f4d..d701ce6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -24,11 +24,7 @@ export default defineConfig(({ mode }) => { }, test: { environment: 'jsdom', - globals: true, - env: { - VITE_SUPABASE_URL: 'https://example.supabase.co', - VITE_SUPABASE_ANON_KEY: 'test-anon-key' - } + globals: true } } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f05b53..5878d38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@supabase/supabase-js': specifier: ^2.49.4 version: 2.98.0 + better-auth: + specifier: ^1.6.25 + version: 1.6.25(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.2(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)) compression: specifier: ^1.8.1 version: 1.8.1 @@ -172,6 +175,9 @@ importers: '@tanstack/react-query': specifier: ^5.75.5 version: 5.90.21(react@19.2.4) + better-auth: + specifier: ^1.6.25 + version: 1.6.25(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.2(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -454,6 +460,85 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@better-auth/core@1.6.25': + resolution: {integrity: sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.7 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.6.25': + resolution: {integrity: sha512-ru/DeKjFPQUVeKkxF/ScazmPqIY7lwfkAV5Yt4j24wmn1Y8vFwoiPRnHgXUeZqBs10+nubaRwEqLF39CP6EhRw==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.6.25': + resolution: {integrity: sha512-zxiePhtN1YClS1irKYPVwWfN6kYp+QoYlz1hdQUOj8hXyo2aE/ny4RNAb6v332b0+U6Vu88EhYITRPdmvCo6uA==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.6.25': + resolution: {integrity: sha512-GhEzTumc8yfTz+OZ6pMg06BA49xob49x1bX+1mEl/FStDJoSF+6mTfI5M2ytFxaiN89336/aUjkW8u+qRyLexw==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.25': + resolution: {integrity: sha512-ZtMmjcOdXR2Ziqx5y8ptTOaNpe0snNfALbBUPXJsgeyeRkDJDYzyLZ8MpuvNBTNllNeIFDbiXWAK5k+pEBZrUQ==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.6.25': + resolution: {integrity: sha512-ym7B6Iqcry+/4aQnYpFwqP/GBIiXvjrm/5B6+0qmx8mkTY/apHFTpHuGzUYYNf4vPTtzF3eYY2+s2GOsomKaRg==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.6.25': + resolution: {integrity: sha512-2ZfC9lp7tU6Jw/q2Lz/bKfQqGMdMwc/IQDTYdBhvtGi24qInYVnhp2ZCW57hHM9j+fq1ULOtxgg6M3T1LEaihw==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-fetch/fetch@1.3.1': + resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -1084,6 +1169,18 @@ packages: peerDependencies: three: '>= 0.159.0' + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} @@ -2236,6 +2333,76 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + better-auth@1.6.25: + resolution: {integrity: sha512-fvoq+oCO+FF5fpP3XfU7znRyGFpHB77UG2EyxsKNy+Cak7Q5pELu+auvvDveQbWQxcoKugZ7jYQQPFQLpUTGOw==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: ^0.45.2 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.3.7: + resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -3046,6 +3213,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.5: + resolution: {integrity: sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3103,6 +3273,10 @@ packages: konva@9.3.22: resolution: {integrity: sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==} + kysely@0.29.4: + resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==} + engines: {node: '>=22.0.0'} + lerc@3.0.0: resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==} @@ -3390,6 +3564,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.4.2: + resolution: {integrity: sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g==} + engines: {node: ^20.0.0 || >=22.0.0} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3774,6 +3952,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -3817,6 +3998,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -4324,6 +4508,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zstddec@0.1.0: resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==} @@ -4663,6 +4850,60 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.7(zod@4.4.3) + jose: 6.2.5 + kysely: 0.29.4 + nanostores: 1.4.2 + zod: 4.4.3 + + '@better-auth/drizzle-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + + '@better-auth/kysely-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.4 + + '@better-auth/memory-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.2(typescript@5.9.3))': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + optionalDependencies: + '@prisma/client': 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) + prisma: 6.19.2(typescript@5.9.3) + + '@better-auth/telemetry@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.2.0 + + '@better-fetch/fetch@1.3.1': {} + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -5085,6 +5326,12 @@ snapshots: promise-worker-transferable: 1.0.4 three: 0.183.2 + '@noble/ciphers@2.2.0': {} + + '@noble/hashes@2.2.0': {} + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@petamoriken/float16@3.9.3': {} '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': @@ -6210,6 +6457,44 @@ snapshots: baseline-browser-mapping@2.10.0: {} + better-auth@1.6.25(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.2(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)): + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/drizzle-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.2(typescript@5.9.3)) + '@better-auth/telemetry': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.7(zod@4.4.3) + defu: 6.1.4 + jose: 6.2.5 + kysely: 0.29.4 + nanostores: 1.4.2 + zod: 4.4.3 + optionalDependencies: + '@prisma/client': 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) + prisma: 6.19.2(typescript@5.9.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.3.7(zod@4.4.3): + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + rou3: 0.7.12 + set-cookie-parser: 3.1.2 + optionalDependencies: + zod: 4.4.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -7078,6 +7363,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.5: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -7157,6 +7444,8 @@ snapshots: konva@9.3.22: {} + kysely@0.29.4: {} + lerc@3.0.0: {} levn@0.4.1: @@ -7526,6 +7815,8 @@ snapshots: nanoid@3.3.11: {} + nanostores@1.4.2: {} + natural-compare@1.4.0: {} negotiator@0.6.4: {} @@ -7934,6 +8225,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + rou3@0.7.12: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -7991,6 +8284,8 @@ snapshots: set-cookie-parser@2.7.2: {} + set-cookie-parser@3.1.2: {} + setprototypeof@1.2.0: {} sharp@0.34.5: @@ -8512,6 +8807,8 @@ snapshots: zod@3.25.76: {} + zod@4.4.3: {} + zstddec@0.1.0: {} zustand@4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4): diff --git a/prisma/migrations/20260730081714_add_better_auth_tables/migration.sql b/prisma/migrations/20260730081714_add_better_auth_tables/migration.sql new file mode 100644 index 0000000..2094965 --- /dev/null +++ b/prisma/migrations/20260730081714_add_better_auth_tables/migration.sql @@ -0,0 +1,82 @@ +-- CreateEnum +CREATE TYPE "UserTier" AS ENUM ('FREE', 'PRO', 'ENTERPRISE'); + +-- CreateTable +CREATE TABLE "user" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "image" TEXT, + "tier" "UserTier" NOT NULL DEFAULT 'FREE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "session" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" TEXT NOT NULL, + + CONSTRAINT "session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "account" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "idToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "scope" TEXT, + "password" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verification" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "verification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); + +-- CreateIndex +CREATE INDEX "session_userId_idx" ON "session"("userId"); + +-- CreateIndex +CREATE INDEX "account_userId_idx" ON "account"("userId"); + +-- CreateIndex +CREATE INDEX "verification_identifier_idx" ON "verification"("identifier"); + +-- AddForeignKey +ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260730083243_add_user_locale/migration.sql b/prisma/migrations/20260730083243_add_user_locale/migration.sql new file mode 100644 index 0000000..29a91dc --- /dev/null +++ b/prisma/migrations/20260730083243_add_user_locale/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "locale" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6331f3e..d24336b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,79 @@ enum ImageryQuality { BASE } +enum UserTier { + FREE + PRO + ENTERPRISE +} + +// Better Auth core tables. Names and fields are dictated by Better Auth's +// Prisma adapter — do not rename them. `tier` is an additionalField carried +// here rather than in a separate `profiles` table: profiles existed only to +// hold the tier and foreign-keyed the retired Supabase `auth.users`. +model User { + id String @id + name String + email String @unique + emailVerified Boolean @default(false) + image String? + tier UserTier @default(FREE) + locale String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sessions Session[] + accounts Account[] + + @@map("user") +} + +model Session { + id String @id + expiresAt DateTime + token String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ipAddress String? + userAgent String? + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("session") +} + +model Account { + id String @id + accountId String + providerId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@map("account") +} + +model Verification { + id String @id + identifier String + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([identifier]) + @@map("verification") +} + model Location { id String @id @default(uuid()) lat Float From f331f5bdaac7ec3e5f8e520f4a2d56ef911b6567 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 16:55:07 +0800 Subject: [PATCH 2/2] fix(db): set connect_timeout=15 so Neon cold starts don't fail requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neon's free tier scales compute to zero when idle. A cold start measured 5.4s, past Prisma's 5s default, so the first request after a quiet period failed with "Can't reach database server" — reproduced as a 500 on the Google sign-in path against a force-suspended compute. --- .env.example | 7 +++++-- RUNBOOK.md | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index f7c3d5a..3af9d04 100644 --- a/.env.example +++ b/.env.example @@ -23,8 +23,11 @@ GOOGLE_OAUTH_SECRET=your_google_oauth_secret # DATABASE_URL is the pooled endpoint (hostname ends in -pooler) used by the running app. # DIRECT_URL is the unpooled endpoint used by Prisma Migrate, which cannot run through a # transaction-mode pooler. Both are shown by `neon connection-string `. -DATABASE_URL=postgresql://user:password@ep-example-pooler.region.aws.neon.tech/neondb?sslmode=require -DIRECT_URL=postgresql://user:password@ep-example.region.aws.neon.tech/neondb?sslmode=require +# `connect_timeout=15` is required, not cosmetic: Neon's free tier scales compute +# to zero when idle and a cold start can exceed Prisma's 5 s default, which fails +# the first request after a quiet period with "Can't reach database server". +DATABASE_URL=postgresql://user:password@ep-example-pooler.region.aws.neon.tech/neondb?sslmode=require&connect_timeout=15 +DIRECT_URL=postgresql://user:password@ep-example.region.aws.neon.tech/neondb?sslmode=require&connect_timeout=15 # Cloudflare R2 object storage (S3-compatible) # The API token needs Object Read & Write on this bucket only. The endpoint is derived as diff --git a/RUNBOOK.md b/RUNBOOK.md index 5f72f4c..d18f807 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -150,6 +150,10 @@ Copy these values from Supabase Settings: Postgres and object storage no longer come from Supabase. Take the database connection strings from Neon (`neon connection-string `, once pooled and once without `--pooled`, into `DATABASE_URL` and `DIRECT_URL`) and the four `R2_*` values from Cloudflare R2. +> **Append `&connect_timeout=15` to both Neon URLs.** The free tier scales compute to zero when idle, and a cold start can exceed Prisma's 5-second default — without this, the first request after a quiet period fails with `Can't reach database server`. Reproduced and fixed on 30/07/26: a suspended compute returned 500 on sign-in without the setting and 200 in 2.8 s with it. +> +> **Never run `neon branches create` bare** — it prints connection URIs, and Neon roles are project-scoped, so a throwaway branch exposes the same password `main` uses. Redirect stdout or use `-o json` and filter. + Create the bucket that stores Solar API GeoTIFFs. The backend expects the bucket name `geotiffs`. ```sql