import { betterAuth } from 'better-auth' import { emailOTP } from 'better-auth/plugins' import { oneTimeToken } from 'better-auth/plugins/one-time-token' import type { Context as hono_Context } from 'hono' import { ColumnNode, type KyselyPlugin, OperationNodeTransformer, OrderByItemNode, PrimitiveValueListNode, type QueryId, QueryNode, ReferenceNode, type SelectQueryNode, TableNode, ValueNode, } from 'kysely' import * as Db from '../db/Db.js' import * as Id from './Id.js' import type * as RateLimit from './RateLimit.js' const idPrefix: Record = { account: 'acc', session: 'ses', user: 'usr', verification: 'ver', } const queryTransformer = new (class extends OperationNodeTransformer { protected override transformSelectQuery(node: SelectQueryNode, queryId?: QueryId) { const transformed = super.transformSelectQuery(node, queryId) // Legacy duplicate emails resolve to the oldest user, matching Tempo's canonical lookup. const readsUsers = transformed.from?.froms.some( (from) => TableNode.is(from) && from.table.identifier.name === 'users', ) if (!readsUsers || transformed.orderBy) return transformed return QueryNode.cloneWithOrderByItems(transformed, [ OrderByItemNode.create(ReferenceNode.create(ColumnNode.create('created_at'))), OrderByItemNode.create(ReferenceNode.create(ColumnNode.create('id'))), ]) } protected override transformPrimitiveValueList(node: PrimitiveValueListNode) { return PrimitiveValueListNode.create(node.values.map(toDatabaseValue)) } protected override transformValue(node: ValueNode) { return node.value instanceof Date ? ValueNode.create(node.value.toISOString()) : node } })() const dateFields = [ 'accessTokenExpiresAt', 'createdAt', 'expiresAt', 'refreshTokenExpiresAt', 'updatedAt', ] as const /** * Creates Better Auth over Tempo's request-leaf Kysely handle. The handler is * not mounted; Tempo routes call the server API explicitly. * * @param source - Database source resolved for this request. * @param options - Better Auth host configuration. * @returns The configured Better Auth server instance. */ export function create(source: Db.Source, options: create.Options) { const db = Db.get(source) const emailOtp = options.emailOtp return betterAuth({ account: { modelName: 'authAccounts' }, advanced: { ...(options.waitUntil ? { backgroundTasks: { handler: options.waitUntil } } : {}), cookiePrefix: 'tempo_auth', database: { generateId: ({ model }) => Id.generate(idPrefix[model] ?? 'auth'), }, defaultCookieAttributes: { httpOnly: true, path: '/', sameSite: 'lax', secure: options.secureCookies, }, ipAddress: { ipAddressHeaders: ['tempo-client-ip'] }, // Keep one cookie name across local and hosted environments; the // explicit attribute above still makes hosted cookies secure. useSecureCookies: false, }, basePath: options.basePath, baseURL: options.baseUrl, database: { // Better Auth uses `Date` values while Tempo stores user timestamps as // canonical ISO text, so its Kysely view converts both query directions. db: db.kysely.withPlugin({ transformQuery: (options) => queryTransformer.transformNode(options.node), async transformResult(options) { return { ...options.result, rows: options.result.rows.map((row) => ({ ...row, ...Object.fromEntries( dateFields.flatMap((field) => typeof row[field] === 'string' ? [[field, new Date(row[field])]] : [], ), ), })), } }, } satisfies KyselyPlugin), transaction: true, type: 'postgres', }, databaseHooks: { session: { create: { async before(session, context) { // Better Auth does not store the sign-in method on sessions. Capture it from the route that creates the session. const provider = (() => { if (context?.path === '/sign-in/email-otp') return 'email' if (context?.path === '/callback/:id') return context.params?.['id'] if ( context?.path === '/sign-in/social' && context.body && typeof context.body === 'object' && 'provider' in context.body && typeof context.body.provider === 'string' ) return context.body.provider })() if (!provider) return return { data: { ...session, provider } } }, }, }, }, // prettier-ignore plugins: [ oneTimeToken({ disableSetSessionCookie: true, expiresIn: 3, storeToken: 'hashed', }), // oxlint-disable-next-line unicorn/no-useless-spread -- Keeps optional plugins independently composable. ...(emailOtp ? [ emailOTP({ allowedAttempts: 3, changeEmail: { enabled: false }, disableSignUp: false, expiresIn: 5 * 60, otpLength: 6, rateLimit: { max: 3, window: 60 }, resendStrategy: 'rotate', sendVerificationOTP: async ({ email, otp, type }) => { if (type !== 'sign-in') throw new UnsupportedOtpTypeError(type) await emailOtp.send({ from: emailOtp.from, html: `

Your Tempo Developer Console sign-in code is:

${otp}

This code expires in five minutes.

If you did not request this code, ignore this email.

`, subject: `${otp} is your Tempo sign-in code`, text: `Your Tempo Developer Console sign-in code is ${otp}. It expires in five minutes. If you did not request this code, ignore this email.`, to: email, }) }, storeOTP: 'hashed', }), ] : []), ], rateLimit: { customRules: { '/sign-in/social': { max: 3, window: 60 }, }, customStorage: { async consume(key, rule) { if (rule.window !== 60) throw new UnsupportedRateLimitWindowError(rule.window) const result = await options.rateLimit.consume({ key: `better_auth:${key}`, limit: { limit: rule.max, period: 'minute' }, }) return { allowed: result.allowed, retryAfter: result.allowed ? null : rule.window, } }, }, enabled: true, max: 100, window: 60, }, secret: options.secret, session: { additionalFields: { provider: { input: false, required: false, type: 'string' }, }, cookieCache: { enabled: false }, disableSessionRefresh: true, expiresIn: 24 * 60 * 60, modelName: 'authSessions', }, socialProviders: options.google ? { google: options.google } : {}, telemetry: { enabled: false }, trustedOrigins: [...new Set([options.baseUrl, ...(options.trustedOrigins ?? [])])], user: { modelName: 'users' }, verification: { modelName: 'authVerifications' }, }) } function toDatabaseValue(value: unknown) { return value instanceof Date ? value.toISOString() : value } export declare namespace create { /** Host configuration for a Better Auth instance. */ type Options = { /** Mounted path that serves Better Auth. */ basePath: string /** Public origin used for cookie and callback URL calculations. */ baseUrl: string /** Email OTP delivery capability. Omit to keep the plugin disabled. */ emailOtp?: | { /** Sender address for sign-in emails. */ from: string /** Sends one sign-in email. */ send: (message: create.EmailMessage) => Promise } | undefined /** Google OpenID Connect credentials. Omit to keep Google sign-in disabled. */ google?: | { /** OAuth client identifier. */ clientId: string /** OAuth client secret. */ clientSecret: string } | undefined /** Atomic store backing Better Auth's request rate limits. */ rateLimit: RateLimit.Store /** High-entropy secret used to protect Better Auth credentials. */ secret: string /** Whether Better Auth cookies carry the `Secure` attribute. */ secureCookies: boolean /** Additional browser origins accepted by Better Auth. */ trustedOrigins?: readonly string[] | undefined /** Keeps deferred Better Auth work alive after the response. */ waitUntil?: ((promise: Promise) => void) | undefined } /** One transactional email. */ type EmailMessage = { /** Sender address. */ from: string /** HTML body. */ html: string /** Subject line. */ subject: string /** Plain-text body. */ text: string /** Recipient address. */ to: string } } /** Creates a request-scoped Better Auth instance from host configuration. */ export function fromContext(c: hono_Context, options: fromContext.Options) { const email = options.email const url = new URL(c.req.url) const forwardedOrigin = (() => { const host = c.req.header('x-forwarded-host') const protocol = c.req.header('x-forwarded-proto') if (!host || (protocol !== 'http' && protocol !== 'https')) return try { return new URL(`${protocol}://${host}`).origin } catch { return } })() const baseUrl = forwardedOrigin && options.trustedOrigins?.includes(forwardedOrigin) ? forwardedOrigin : url.origin const waitUntil = getWaitUntil(c) return create(options.db, { basePath: options.basePath, baseUrl, ...(email ? { emailOtp: { from: email.from, send: email.send } } : {}), ...(options.google ? { google: options.google } : {}), rateLimit: options.rateLimit, secret: options.secret, secureCookies: new URL(baseUrl).protocol === 'https:', trustedOrigins: options.trustedOrigins, ...(waitUntil ? { waitUntil } : {}), }) } /** Returns the request with the host-resolved client IP in a private header. */ export function request(c: hono_Context, options: fromContext.Options) { const request = c.req.raw const clientIp = options.clientIp(request) const headers = new Headers(request.headers) if (clientIp) headers.set('tempo-client-ip', clientIp) else headers.delete('tempo-client-ip') // Cloudflare's cloned Request generic is narrower than the global constructor accepts. return new Request(request.clone() as never, { headers }) } export declare namespace fromContext { /** Static Better Auth configuration derived by the host app. */ type Options = Omit & { /** Resolves a client IP from trusted runtime or proxy metadata. */ clientIp: (request: Request) => string | undefined /** Database source resolved for this Better Auth instance. */ db: Db.Source /** Transactional email capability, or undefined when email is unavailable. */ email: Email | undefined } /** Minimal transactional email capability provided by the host. */ type Email = { /** Sender address for sign-in emails. */ from: string /** Sends one sign-in email. */ send: (message: create.EmailMessage) => Promise } } function getWaitUntil(c: hono_Context) { try { const context = c.executionCtx return context ? context.waitUntil.bind(context) : undefined } catch { return undefined } } /** Raised if a mounted Better Auth route requests an unsupported rate-limit window. */ export class UnsupportedRateLimitWindowError extends Error { override name = 'BetterAuth.UnsupportedRateLimitWindowError' constructor(window: number) { super(`Better Auth rate-limit window ${window}s is unsupported.`) } } /** Raised if code accidentally enables an unsupported Better Auth OTP purpose. */ export class UnsupportedOtpTypeError extends Error { override name = 'BetterAuth.UnsupportedOtpTypeError' }