import { betterAuth } from "better-auth"; import { Pool } from "pg"; import { buildAuthOptions, type AdminUserAuthorizer } from "./auth-factory.ts"; import { isSelfHostedIdentityEnabled, readSelfHostedIdentityConfig, type SelfHostedIdentityConfig, } from "./config.ts"; import type { EmailOtpSender } from "./contracts.ts"; import { FakeEmailOtpSender } from "./email/fake-email-otp-sender.ts"; import { ResendEmailOtpSender } from "./email/resend-email-otp-sender.ts"; interface AdminRoleRow { role: string; banned: boolean; ban_expires: Date | null; } export type IdentityAdminSurfaceRole = "admin"; export function createIdentityPool(databaseUrl: string): Pool { return new Pool({ connectionString: databaseUrl, options: "-c search_path=identity,pg_catalog", application_name: "jyotisha-identity", max: 10, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 5_000, }); } function createDatabaseRoleAuthorizer( pool: Pool, allowedRoles: ReadonlySet, ): AdminUserAuthorizer { return async (userId) => { const result = await pool.query( ` select role, banned, ban_expires from identity.users where id = $1 limit 1 `, [userId], ); const user = result.rows[0]; if (!user) return false; if (user.banned) { const banExpiry = user.ban_expires?.getTime(); if (banExpiry === undefined || !Number.isFinite(banExpiry)) return false; if (banExpiry > Date.now()) return false; } return user.role .split(",") .map((role) => role.trim()) .some((role) => allowedRoles.has(role)); }; } export function createDatabaseAdminAuthorizer( pool: Pool, ): AdminUserAuthorizer { return createDatabaseRoleAuthorizer(pool, new Set(["admin"])); } export function createDatabaseAdminSurfaceAuthorizer( pool: Pool, ): AdminUserAuthorizer { return createDatabaseAdminAuthorizer(pool); } export interface IdentityAuthServices { pool: Pool; user: ReturnType; } interface IdentityAuthDependencies { pool?: Pool; emailSender?: EmailOtpSender; } export function createIdentityAuthServices( config: SelfHostedIdentityConfig, dependencies: IdentityAuthDependencies = {}, ): IdentityAuthServices { const pool = dependencies.pool ?? createIdentityPool(config.databaseUrl); const emailSender = dependencies.emailSender ?? (config.testOtp ? new FakeEmailOtpSender() : new ResendEmailOtpSender({ apiKey: config.resendApiKey, from: config.resendFrom, })); return { pool, user: betterAuth( buildAuthOptions({ config, database: pool, emailSender, }), ), }; } export interface IdentityEmailOtpApi { sendVerificationOTP(input: { body: { email: string; type: "email-verification" }; }): Promise<{ success: boolean }>; verifyEmailOTP(input: { body: { email: string; otp: string }; }): Promise; } export function getIdentityEmailOtpApi( env: NodeJS.ProcessEnv = process.env, ): IdentityEmailOtpApi { return getIdentityAuthServices(env).user.api as unknown as IdentityEmailOtpApi; } export interface IdentityTwoFactorApi { enableTwoFactor(input: { body: { password: string }; headers: Headers; asResponse: true; }): Promise; verifyTOTP(input: { body: { code: string; trustDevice?: boolean }; headers: Headers; asResponse: true; }): Promise; verifyBackupCode(input: { body: { code: string; disableSession?: boolean }; headers: Headers; asResponse: true; }): Promise; generateBackupCodes(input: { body: { password: string }; headers: Headers; asResponse: true; }): Promise; disableTwoFactor(input: { body: { password: string }; headers: Headers; asResponse: true; }): Promise; } export function getIdentityTwoFactorApi( env: NodeJS.ProcessEnv = process.env, ): IdentityTwoFactorApi { return getIdentityAuthServices(env).user.api as unknown as IdentityTwoFactorApi; } const identityGlobal = globalThis as typeof globalThis & { jyotishaIdentityAuth?: IdentityAuthServices; }; export function getIdentityAuthServices( env: NodeJS.ProcessEnv = process.env, ): IdentityAuthServices { if (!isSelfHostedIdentityEnabled(env)) { throw new Error("self-hosted identity is not enabled"); } const config = readSelfHostedIdentityConfig(env); identityGlobal.jyotishaIdentityAuth ??= createIdentityAuthServices(config); return identityGlobal.jyotishaIdentityAuth; }