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 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