import type { TransactionalEmail, TransactionalEmailProvider } from "@rakazo/adapter-kit"; import { emailAllowed, isMessagingEmail, parseAllowlist, signupPolicyFromEnv } from "@rakazo/core"; import { bootstrapUserSpace, type PrismaClient } from "@rakazo/db"; import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { APIError, createAuthMiddleware } from "better-auth/api"; import { bearer, organization } from "better-auth/plugins"; export interface AuthEnv { secret: string; baseURL: string; webOrigin: string; signupsEnabled: string | undefined; signupAllowlist: string | undefined; extraOrigins?: string[]; email?: TransactionalEmailProvider; onEmailError?: (error: unknown) => void; beforeDeleteUser?: (userId: string) => Promise; } export async function resolveSignupPolicy( prisma: Pick, env: Pick, ): Promise<{ enabled: boolean; allowlist: string[] }> { const settings = await prisma.deploymentSettings.findUnique({ where: { id: "default" }, select: { signupsEnabled: true, signupAllowlist: true, signupPolicyInitialized: true }, }); if (settings?.signupPolicyInitialized) { return { enabled: settings.signupsEnabled, allowlist: parseAllowlist(settings.signupAllowlist), }; } return signupPolicyFromEnv(env); } export function createAuth(prisma: PrismaClient, env: AuthEnv) { return betterAuth({ appName: "EvOo", secret: env.secret, baseURL: env.baseURL, trustedOrigins: buildTrustedOrigins(env), database: prismaAdapter(prisma, { provider: "postgresql" }), emailAndPassword: { enabled: true, // Signup policy is mutable deployment state, so the request hook below // enforces it instead of freezing an environment value at process start. disableSignUp: false, revokeSessionsOnPasswordReset: true, resetPasswordTokenExpiresIn: 60 * 60, sendResetPassword: env.email ? async ({ user, url }) => { // Keep the response timing generic. Production providers track and retry the promise, // while the composition root drains accepted delivery during graceful shutdown. void env.email ?.send(passwordResetEmail(user, url)) .catch((error) => env.onEmailError?.(error)); } : undefined, }, emailVerification: { sendOnSignIn: true, autoSignInAfterVerification: false, sendVerificationEmail: env.email ? async ({ user, url }) => { const verificationUrl = new URL(url); verificationUrl.searchParams.set( "callbackURL", new URL("/sign-in", env.webOrigin).href, ); await env.email!.send(verificationEmail(user.email, verificationUrl.href)); } : undefined, }, user: { deleteUser: { enabled: true, beforeDelete: async (user) => { await env.beforeDeleteUser?.(user.id); const memberships = await prisma.member.findMany({ where: { userId: user.id }, select: { organizationId: true, organization: { select: { members: { select: { userId: true } } } }, }, }); const personalOrganizationIds = memberships .filter(({ organization }) => organization.members.every((member) => member.userId === user.id), ) .map(({ organizationId }) => organizationId); await prisma.$transaction([ prisma.deploymentSettings.updateMany({ where: { ownerUserId: user.id }, data: { ownerUserId: null }, }), // Messaging identities are deliberately FK-free, so clear them // here or the unique address would point at a deleted bot forever. prisma.messagingIdentity.deleteMany({ where: { userId: user.id }, }), prisma.organization.deleteMany({ where: { id: { in: personalOrganizationIds } }, }), ]); }, }, }, plugins: [ bearer(), organization({ allowUserToCreateOrganization: false, creatorRole: "owner", }), ], hooks: { before: createAuthMiddleware(async (ctx) => { for (const value of [ctx.body?.email, ctx.body?.newEmail]) { if (typeof value === "string" && isMessagingEmail(value)) { throw new APIError("BAD_REQUEST", { message: "Email is not available" }); } } let policy = ctx.path === "/sign-up/email" || ctx.path === "/sign-in/email" ? await resolveSignupPolicy(prisma, env) : undefined; if (ctx.path === "/sign-up/email") { if (!policy?.enabled) { throw new APIError("BAD_REQUEST", { message: "Registration is closed" }); } if (!emailAllowed(String(ctx.body?.email ?? ""), policy.allowlist)) { throw new APIError("BAD_REQUEST", { message: "Email is not allowed to register" }); } if (policy.allowlist.length > 0 && !env.email) { throw new APIError("BAD_REQUEST", { message: "Registration requires email delivery" }); } } // Return a request-local override; mutating the shared auth options // would leak a concurrent request's policy into another signup. return { context: { context: { ...(policy ? { options: { emailAndPassword: { requireEmailVerification: policy.allowlist.length > 0 }, }, } : {}), internalAdapter: { ...ctx.context.internalAdapter, // Authorize at lookup: bearer conversion happens after before // hooks, and auth mutations also read sessions through here. findSession: async (token: string) => { const session = await ctx.context.internalAdapter.findSession(token); if (!session || isMessagingEmail(session.user.email)) return null; if (session.user.emailVerified) return session; policy ??= await resolveSignupPolicy(prisma, env); return policy.allowlist.length === 0 ? session : null; }, }, }, }, }; }), }, databaseHooks: { session: { create: { before: async (session, ctx) => { // The auth adapter can still be inside the signup transaction. const user = await ctx?.context.internalAdapter.findUserById(session.userId); const policy = await resolveSignupPolicy(prisma, env); if ( !user || isMessagingEmail(user.email) || (!user.emailVerified && policy.allowlist.length > 0) ) { throw new APIError("FORBIDDEN", { message: "Email verification required" }); } // Unverified signup must not provision resources or claim the // deployment owner. Bootstrap only at the first admitted session. const membership = await prisma.spaceMember.findFirst({ where: { userId: user.id } }); if (!membership) { if (!policy.enabled || !emailAllowed(user.email, policy.allowlist)) { throw new APIError("FORBIDDEN", { message: "Registration is closed" }); } await bootstrapUserSpace(prisma, user, env); } }, }, }, user: { create: { before: async (user) => { if (isMessagingEmail(user.email)) { throw new APIError("BAD_REQUEST", { message: "Email is not available" }); } }, }, update: { before: async (user) => { if (user.email && isMessagingEmail(user.email)) { throw new APIError("BAD_REQUEST", { message: "Email is not available" }); } }, }, }, }, }); } export function verificationEmail(email: string, url: string): TransactionalEmail { return { to: email, subject: "Verify your Rakazo email", text: `Verify your email, then return to Rakazo to sign in:\n\n${url}\n\nThis link expires in one hour. If you did not register, ignore this email.`, html: `

Verify email, then return to Rakazo to sign in.

This link expires in one hour. If you did not register, ignore this email.

`, }; } export function passwordResetEmail( user: { id: string; email: string; name: string }, resetUrl: string, ): TransactionalEmail { const name = user.name.trim() || "there"; const safeName = escapeHtml(name); const safeUrl = escapeHtml(resetUrl); return { to: user.email, subject: "Reset your Rakazo password", text: [ `Hi ${name},`, "", "Reset your Rakazo password using this link:", resetUrl, "", "This link expires in one hour. If you did not request this, you can ignore this email.", ].join("\n"), html: `

Hi ${safeName},

Reset your Rakazo password:

Reset password

This link expires in one hour. If you did not request this, you can ignore this email.

`, }; } function escapeHtml(value: string): string { return value.replace( /[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!, ); } export type Auth = ReturnType; /** Assemble Better Auth trustedOrigins, adding localhost↔127.0.0.1 twins for loopback. */ export function buildTrustedOrigins(env: Pick) { const configured = [env.webOrigin, env.baseURL, ...(env.extraOrigins ?? [])]; const twins = [env.webOrigin, env.baseURL].flatMap(loopbackTwinOrigins); return [...new Set([...configured, ...twins])]; } function isLoopbackHost(host: string): boolean { return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]"; } /** Same-scheme/port localhost and 127.0.0.1 variants when `origin` is loopback. */ function loopbackTwinOrigins(origin: string): string[] { try { const url = new URL(origin); if (!isLoopbackHost(url.hostname)) return []; const twins: string[] = []; for (const host of ["localhost", "127.0.0.1"] as const) { if (host === url.hostname) continue; const twin = new URL(origin); twin.hostname = host; twins.push(twin.origin); } return twins; } catch { return []; } } export const blockedAuthPaths = [ "/organization/create", "/organization/invite", "/organization/accept-invitation", "/organization/reject-invitation", "/organization/remove-member", "/organization/update-member-role", ];