diff --git a/.env.example b/.env.example index 6598696..4421ffc 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,6 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres BETTER_AUTH_SECRET=replace-with-a-long-random-secret BETTER_AUTH_BASE_URL=http://localhost:3000 -BETTER_AUTH_ADMIN_USER_IDS= BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 # OpenClaw / ZeroClaw (OpenAI-compatible) diff --git a/README.md b/README.md index 125240c..18a2f95 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with OpenClaw integration. - Bun runtime/tooling - Elysia route layer mounted in Next Route Handlers - Turbopack for `dev` and `build` -- Better Auth (email/password, magic link, admin, organization plugins) +- Better Auth (email/password + magic link) - Drizzle ORM (PostgreSQL) + Better Auth Drizzle adapter - Internal API routes via Elysia catch-all (`app/api/[[...slugs]]/route.ts`) - Durable local task engine and JSON data store @@ -66,7 +66,6 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres BETTER_AUTH_SECRET=replace-with-a-long-random-secret BETTER_AUTH_BASE_URL=http://localhost:3000 -BETTER_AUTH_ADMIN_USER_IDS= BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 OPENCLAW_BASE_URL=http://localhost:4000 @@ -81,7 +80,7 @@ If OpenClaw is unset, the app uses local fallback analysis so task workflows sti All endpoints below are handled by Elysia in `app/api/[[...slugs]]/route.ts`. -- `GET|POST|PATCH|PUT|DELETE /api/auth/*` (Better Auth handler) +- `ALL /api/auth/*` (Better Auth handler) - `GET /api/health` - `GET /api/me` - `GET|POST /api/watchlist` diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index ba8ef2d..b6a7990 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -1,6 +1,6 @@ import { Elysia } from 'elysia'; import type { Holding, TaskStatus, WatchlistItem } from '@/lib/types'; -import { ensureAuthSchema } from '@/lib/auth'; +import { auth } from '@/lib/auth'; import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { asErrorMessage, jsonError } from '@/lib/server/http'; import { buildPortfolioSummary, recalculateHolding } from '@/lib/server/portfolio'; @@ -26,24 +26,11 @@ function asPositiveNumber(value: unknown) { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } -async function handleAuthRequest(request: Request) { - const auth = await ensureAuthSchema(); - return auth.handler(request); -} +const authHandler = ({ request }: { request: Request }) => auth.handler(request); export const app = new Elysia({ prefix: '/api' }) - .get('/auth', ({ request }) => handleAuthRequest(request)) - .post('/auth', ({ request }) => handleAuthRequest(request)) - .patch('/auth', ({ request }) => handleAuthRequest(request)) - .put('/auth', ({ request }) => handleAuthRequest(request)) - .delete('/auth', ({ request }) => handleAuthRequest(request)) - .get('/auth/*', ({ request }) => handleAuthRequest(request)) - .post('/auth/*', ({ request }) => handleAuthRequest(request)) - .patch('/auth/*', ({ request }) => handleAuthRequest(request)) - .put('/auth/*', ({ request }) => handleAuthRequest(request)) - .delete('/auth/*', ({ request }) => handleAuthRequest(request)) - .options('/auth', ({ request }) => handleAuthRequest(request)) - .options('/auth/*', ({ request }) => handleAuthRequest(request)) + .all('/auth', authHandler) + .all('/auth/*', authHandler) .get('/health', async () => { const snapshot = await getStoreSnapshot(); const queue = snapshot.tasks.reduce>((acc, task) => { diff --git a/lib/auth-client.ts b/lib/auth-client.ts index cf9f4e5..08c7afb 100644 --- a/lib/auth-client.ts +++ b/lib/auth-client.ts @@ -1,5 +1,5 @@ import { createAuthClient } from 'better-auth/react'; -import { adminClient, magicLinkClient, organizationClient } from 'better-auth/client/plugins'; +import { magicLinkClient } from 'better-auth/client/plugins'; import { resolveApiBaseURL } from '@/lib/runtime-url'; const baseURL = resolveApiBaseURL(process.env.NEXT_PUBLIC_API_URL); @@ -7,8 +7,6 @@ const baseURL = resolveApiBaseURL(process.env.NEXT_PUBLIC_API_URL); export const authClient = createAuthClient({ baseURL: baseURL || undefined, plugins: [ - adminClient(), - magicLinkClient(), - organizationClient() + magicLinkClient() ] }); diff --git a/lib/auth.ts b/lib/auth.ts index 0fde18d..7153fc3 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,14 +1,10 @@ import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { nextCookies } from 'better-auth/next-js'; -import { admin, magicLink, organization } from 'better-auth/plugins'; +import { magicLink } from 'better-auth/plugins'; import { db } from '@/lib/server/db'; import { authSchema } from '@/lib/server/db/schema'; -type BetterAuthInstance = ReturnType; - -let authInstance: BetterAuthInstance | null = null; - function parseCsvList(value: string | undefined) { return (value ?? '') .split(',') @@ -16,46 +12,29 @@ function parseCsvList(value: string | undefined) { .filter((entry) => entry.length > 0); } -function buildAuth() { - const adminUserIds = parseCsvList(process.env.BETTER_AUTH_ADMIN_USER_IDS); - const trustedOrigins = parseCsvList(process.env.BETTER_AUTH_TRUSTED_ORIGINS); - const baseURL = process.env.BETTER_AUTH_BASE_URL?.trim() - || process.env.BETTER_AUTH_URL?.trim() - || undefined; - const secret = process.env.BETTER_AUTH_SECRET?.trim() || undefined; +const trustedOrigins = parseCsvList(process.env.BETTER_AUTH_TRUSTED_ORIGINS); +const baseURL = process.env.BETTER_AUTH_BASE_URL?.trim() + || process.env.BETTER_AUTH_URL?.trim() + || undefined; +const secret = process.env.BETTER_AUTH_SECRET?.trim() || undefined; - return betterAuth({ - database: drizzleAdapter(db, { - provider: 'pg', - schema: authSchema +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: 'pg', + schema: authSchema + }), + baseURL, + secret, + emailAndPassword: { + enabled: true + }, + trustedOrigins: trustedOrigins.length > 0 ? trustedOrigins : undefined, + plugins: [ + magicLink({ + sendMagicLink: async ({ email, url }) => { + console.info(`[better-auth] Magic link requested for ${email}: ${url}`); + } }), - baseURL, - secret, - emailAndPassword: { - enabled: true - }, - trustedOrigins: trustedOrigins.length > 0 ? trustedOrigins : undefined, - plugins: [ - admin(adminUserIds.length > 0 ? { adminUserIds } : undefined), - magicLink({ - sendMagicLink: async ({ email, url }) => { - console.info(`[better-auth] Magic link requested for ${email}: ${url}`); - } - }), - organization(), - nextCookies() - ] - }); -} - -export function getAuth() { - if (!authInstance) { - authInstance = buildAuth(); - } - - return authInstance; -} - -export async function ensureAuthSchema() { - return getAuth(); -} + nextCookies() + ] +}); diff --git a/lib/server/auth-session.ts b/lib/server/auth-session.ts index 66fdf3f..4321be3 100644 --- a/lib/server/auth-session.ts +++ b/lib/server/auth-session.ts @@ -1,109 +1,40 @@ import { headers } from 'next/headers'; -import { ensureAuthSchema } from '@/lib/auth'; +import { auth } from '@/lib/auth'; import { asErrorMessage, jsonError } from '@/lib/server/http'; -type RecordValue = Record; +export type AuthenticatedSession = NonNullable< + Awaited> +>; -export type AuthenticatedUser = { - id: string; - email: string; - name: string | null; - image: string | null; - role?: string | string[]; -}; - -export type AuthenticatedSession = { - user: AuthenticatedUser; - session: RecordValue | null; - raw: RecordValue; -}; - -const UNAUTHORIZED_SESSION: AuthenticatedSession = { - user: { - id: '', - email: '', - name: null, - image: null - }, - session: null, - raw: {} -}; - -function asRecord(value: unknown): RecordValue | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null; +type RequiredSessionResult = ( + | { + session: AuthenticatedSession; + response: null; } - - return value as RecordValue; -} - -function asString(value: unknown) { - return typeof value === 'string' && value.trim().length > 0 ? value : null; -} - -function asNullableString(value: unknown) { - return typeof value === 'string' ? value : null; -} - -function normalizeRole(value: unknown) { - if (typeof value === 'string') { - return value; + | { + session: null; + response: Response; } - - if (Array.isArray(value)) { - const roles = value.filter((entry): entry is string => typeof entry === 'string'); - return roles.length > 0 ? roles : undefined; - } - - return undefined; -} - -function normalizeSession(rawSession: unknown): AuthenticatedSession | null { - const root = asRecord(rawSession); - if (!root) { - return null; - } - - const rootSession = asRecord(root.session); - const userRecord = asRecord(root.user) ?? asRecord(rootSession?.user); - if (!userRecord) { - return null; - } - - const id = asString(userRecord.id); - const email = asString(userRecord.email); - if (!id || !email) { - return null; - } - - return { - user: { - id, - email, - name: asNullableString(userRecord.name), - image: asNullableString(userRecord.image), - role: normalizeRole(userRecord.role) - }, - session: rootSession, - raw: root - }; -} +); export async function getAuthenticatedSession() { - const auth = await ensureAuthSchema(); const session = await auth.api.getSession({ headers: await headers() }); - return normalizeSession(session); + if (!session?.user?.id) { + return null; + } + + return session; } -export async function requireAuthenticatedSession() { +export async function requireAuthenticatedSession(): Promise { try { const session = await getAuthenticatedSession(); if (!session) { return { - session: UNAUTHORIZED_SESSION, + session: null, response: jsonError('Unauthorized', 401) }; } @@ -114,7 +45,7 @@ export async function requireAuthenticatedSession() { }; } catch (error) { return { - session: UNAUTHORIZED_SESSION, + session: null, response: jsonError(asErrorMessage(error, 'Authentication subsystem is unavailable.'), 500) }; }