simplify better-auth integration in elysia routes

This commit is contained in:
2026-02-24 21:04:06 -05:00
parent 122030e487
commit ae9f081f90
6 changed files with 53 additions and 160 deletions

View File

@@ -13,7 +13,6 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
BETTER_AUTH_SECRET=replace-with-a-long-random-secret BETTER_AUTH_SECRET=replace-with-a-long-random-secret
BETTER_AUTH_BASE_URL=http://localhost:3000 BETTER_AUTH_BASE_URL=http://localhost:3000
BETTER_AUTH_ADMIN_USER_IDS=
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
# OpenClaw / ZeroClaw (OpenAI-compatible) # OpenClaw / ZeroClaw (OpenAI-compatible)

View File

@@ -8,7 +8,7 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with OpenClaw integration.
- Bun runtime/tooling - Bun runtime/tooling
- Elysia route layer mounted in Next Route Handlers - Elysia route layer mounted in Next Route Handlers
- Turbopack for `dev` and `build` - 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 - Drizzle ORM (PostgreSQL) + Better Auth Drizzle adapter
- Internal API routes via Elysia catch-all (`app/api/[[...slugs]]/route.ts`) - Internal API routes via Elysia catch-all (`app/api/[[...slugs]]/route.ts`)
- Durable local task engine and JSON data store - Durable local task engine and JSON data store
@@ -66,7 +66,6 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
BETTER_AUTH_SECRET=replace-with-a-long-random-secret BETTER_AUTH_SECRET=replace-with-a-long-random-secret
BETTER_AUTH_BASE_URL=http://localhost:3000 BETTER_AUTH_BASE_URL=http://localhost:3000
BETTER_AUTH_ADMIN_USER_IDS=
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
OPENCLAW_BASE_URL=http://localhost:4000 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`. 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/health`
- `GET /api/me` - `GET /api/me`
- `GET|POST /api/watchlist` - `GET|POST /api/watchlist`

View File

@@ -1,6 +1,6 @@
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import type { Holding, TaskStatus, WatchlistItem } from '@/lib/types'; 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 { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { asErrorMessage, jsonError } from '@/lib/server/http'; import { asErrorMessage, jsonError } from '@/lib/server/http';
import { buildPortfolioSummary, recalculateHolding } from '@/lib/server/portfolio'; import { buildPortfolioSummary, recalculateHolding } from '@/lib/server/portfolio';
@@ -26,24 +26,11 @@ function asPositiveNumber(value: unknown) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null; return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
} }
async function handleAuthRequest(request: Request) { const authHandler = ({ request }: { request: Request }) => auth.handler(request);
const auth = await ensureAuthSchema();
return auth.handler(request);
}
export const app = new Elysia({ prefix: '/api' }) export const app = new Elysia({ prefix: '/api' })
.get('/auth', ({ request }) => handleAuthRequest(request)) .all('/auth', authHandler)
.post('/auth', ({ request }) => handleAuthRequest(request)) .all('/auth/*', authHandler)
.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))
.get('/health', async () => { .get('/health', async () => {
const snapshot = await getStoreSnapshot(); const snapshot = await getStoreSnapshot();
const queue = snapshot.tasks.reduce<Record<string, number>>((acc, task) => { const queue = snapshot.tasks.reduce<Record<string, number>>((acc, task) => {

View File

@@ -1,5 +1,5 @@
import { createAuthClient } from 'better-auth/react'; 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'; import { resolveApiBaseURL } from '@/lib/runtime-url';
const baseURL = resolveApiBaseURL(process.env.NEXT_PUBLIC_API_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({ export const authClient = createAuthClient({
baseURL: baseURL || undefined, baseURL: baseURL || undefined,
plugins: [ plugins: [
adminClient(), magicLinkClient()
magicLinkClient(),
organizationClient()
] ]
}); });

View File

@@ -1,14 +1,10 @@
import { betterAuth } from 'better-auth'; import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { nextCookies } from 'better-auth/next-js'; 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 { db } from '@/lib/server/db';
import { authSchema } from '@/lib/server/db/schema'; import { authSchema } from '@/lib/server/db/schema';
type BetterAuthInstance = ReturnType<typeof betterAuth>;
let authInstance: BetterAuthInstance | null = null;
function parseCsvList(value: string | undefined) { function parseCsvList(value: string | undefined) {
return (value ?? '') return (value ?? '')
.split(',') .split(',')
@@ -16,15 +12,13 @@ function parseCsvList(value: string | undefined) {
.filter((entry) => entry.length > 0); .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 trustedOrigins = parseCsvList(process.env.BETTER_AUTH_TRUSTED_ORIGINS);
const baseURL = process.env.BETTER_AUTH_BASE_URL?.trim() const baseURL = process.env.BETTER_AUTH_BASE_URL?.trim()
|| process.env.BETTER_AUTH_URL?.trim() || process.env.BETTER_AUTH_URL?.trim()
|| undefined; || undefined;
const secret = process.env.BETTER_AUTH_SECRET?.trim() || undefined; const secret = process.env.BETTER_AUTH_SECRET?.trim() || undefined;
return betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: 'pg', provider: 'pg',
schema: authSchema schema: authSchema
@@ -36,26 +30,11 @@ function buildAuth() {
}, },
trustedOrigins: trustedOrigins.length > 0 ? trustedOrigins : undefined, trustedOrigins: trustedOrigins.length > 0 ? trustedOrigins : undefined,
plugins: [ plugins: [
admin(adminUserIds.length > 0 ? { adminUserIds } : undefined),
magicLink({ magicLink({
sendMagicLink: async ({ email, url }) => { sendMagicLink: async ({ email, url }) => {
console.info(`[better-auth] Magic link requested for ${email}: ${url}`); console.info(`[better-auth] Magic link requested for ${email}: ${url}`);
} }
}), }),
organization(),
nextCookies() nextCookies()
] ]
}); });
}
export function getAuth() {
if (!authInstance) {
authInstance = buildAuth();
}
return authInstance;
}
export async function ensureAuthSchema() {
return getAuth();
}

View File

@@ -1,109 +1,40 @@
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { ensureAuthSchema } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { asErrorMessage, jsonError } from '@/lib/server/http'; import { asErrorMessage, jsonError } from '@/lib/server/http';
type RecordValue = Record<string, unknown>; export type AuthenticatedSession = NonNullable<
Awaited<ReturnType<typeof auth.api.getSession>>
>;
export type AuthenticatedUser = { type RequiredSessionResult = (
id: string; | {
email: string; session: AuthenticatedSession;
name: string | null; response: 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;
} }
| {
return value as RecordValue; session: null;
} response: Response;
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;
}
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() { export async function getAuthenticatedSession() {
const auth = await ensureAuthSchema();
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers() headers: await headers()
}); });
return normalizeSession(session); if (!session?.user?.id) {
return null;
} }
export async function requireAuthenticatedSession() { return session;
}
export async function requireAuthenticatedSession(): Promise<RequiredSessionResult> {
try { try {
const session = await getAuthenticatedSession(); const session = await getAuthenticatedSession();
if (!session) { if (!session) {
return { return {
session: UNAUTHORIZED_SESSION, session: null,
response: jsonError('Unauthorized', 401) response: jsonError('Unauthorized', 401)
}; };
} }
@@ -114,7 +45,7 @@ export async function requireAuthenticatedSession() {
}; };
} catch (error) { } catch (error) {
return { return {
session: UNAUTHORIZED_SESSION, session: null,
response: jsonError(asErrorMessage(error, 'Authentication subsystem is unavailable.'), 500) response: jsonError(asErrorMessage(error, 'Authentication subsystem is unavailable.'), 500)
}; };
} }