Files
Neon-Desk/lib/auth.ts

161 lines
4.3 KiB
TypeScript

import { betterAuth } from 'better-auth';
import { getMigrations } from 'better-auth/db';
import { nextCookies } from 'better-auth/next-js';
import { admin, magicLink, organization } from 'better-auth/plugins';
import { Pool } from 'pg';
declare global {
// eslint-disable-next-line no-var
var __fiscalAuthPgPool: Pool | undefined;
}
type BetterAuthInstance = ReturnType<typeof betterAuth>;
let authInstance: BetterAuthInstance | null = null;
let migrationPromise: Promise<void> | null = null;
function parseCsvList(value: string | undefined) {
return (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function isPostgresConnectionString(value: string | undefined) {
if (!value) {
return false;
}
try {
const protocol = new URL(value).protocol.toLowerCase();
return protocol === 'postgres:' || protocol === 'postgresql:';
} catch {
return /^postgres(?:ql)?:\/\//i.test(value);
}
}
function splitSqlStatements(sqlText: string) {
return sqlText
.split(';')
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
function buildPostgresMigrationPlan(sqlText: string) {
const immediateStatements: string[] = [];
const deferredIndexStatements: string[] = [];
const addIndexPattern = /^alter table\s+([^\s]+)\s+add\s+index\s+([^\s]+)\s+\((.+)\)$/i;
for (const rawStatement of splitSqlStatements(sqlText)) {
const statement = rawStatement.replace(/\s+/g, ' ').trim();
const match = statement.match(addIndexPattern);
if (!match) {
immediateStatements.push(statement);
continue;
}
const [, tableName, indexName, columns] = match;
deferredIndexStatements.push(`create index if not exists ${indexName} on ${tableName} (${columns})`);
}
return [...immediateStatements, ...deferredIndexStatements];
}
async function runPostgresMigrations(pool: Pool, sqlText: string) {
const statements = buildPostgresMigrationPlan(sqlText);
if (statements.length === 0) {
return;
}
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const statement of statements) {
await client.query(statement);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
function getPool() {
const connectionString = process.env.DATABASE_URL?.trim();
if (!connectionString) {
throw new Error('DATABASE_URL is required for Better Auth PostgreSQL adapter.');
}
if (!globalThis.__fiscalAuthPgPool) {
globalThis.__fiscalAuthPgPool = new Pool({ connectionString });
}
return globalThis.__fiscalAuthPgPool;
}
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;
return betterAuth({
database: getPool(),
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() {
const auth = getAuth();
const connectionString = process.env.DATABASE_URL?.trim();
if (!migrationPromise) {
migrationPromise = (async () => {
const migrations = await getMigrations(auth.options);
if (isPostgresConnectionString(connectionString)) {
const sql = await migrations.compileMigrations();
await runPostgresMigrations(getPool(), sql);
return;
}
await migrations.runMigrations();
})();
}
try {
await migrationPromise;
} catch (error) {
migrationPromise = null;
throw error;
}
return auth;
}