simplify better-auth integration in elysia routes
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
71
lib/auth.ts
71
lib/auth.ts
@@ -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,46 +12,29 @@ function parseCsvList(value: string | undefined) {
|
|||||||
.filter((entry) => entry.length > 0);
|
.filter((entry) => entry.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAuth() {
|
const trustedOrigins = parseCsvList(process.env.BETTER_AUTH_TRUSTED_ORIGINS);
|
||||||
const adminUserIds = parseCsvList(process.env.BETTER_AUTH_ADMIN_USER_IDS);
|
const baseURL = process.env.BETTER_AUTH_BASE_URL?.trim()
|
||||||
const trustedOrigins = parseCsvList(process.env.BETTER_AUTH_TRUSTED_ORIGINS);
|
|| process.env.BETTER_AUTH_URL?.trim()
|
||||||
const baseURL = process.env.BETTER_AUTH_BASE_URL?.trim()
|
|| undefined;
|
||||||
|| process.env.BETTER_AUTH_URL?.trim()
|
const secret = process.env.BETTER_AUTH_SECRET?.trim() || undefined;
|
||||||
|| 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
|
||||||
|
}),
|
||||||
|
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,
|
nextCookies()
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuthenticatedSession() {
|
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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user