import { betterAuth } from 'better-auth' import { admin, magicLink } from 'better-auth/plugins' import { actor, fabric } from '@pikku/better-auth' import { pikkuBetterAuth } from '#pikku/pikku-types.gen.js' import { UserRoles, UserTypes } from '@heygermany/sdk' import type { EmailTemplateName } from '../../.pikku/email/pikku-emails.gen.js' const DEV_BETTER_AUTH_SECRET = 'dev-better-auth-secret-change-me' const isLocalDomain = (domain: string) => { return !domain || domain.includes('localhost') || domain.includes('127.0.0.1') } const getOrigin = (domain: string) => { if (isLocalDomain(domain)) { return 'http://localhost:3000' } return `https://api.${domain}` } export const auth = pikkuBetterAuth(async ({ kysely, secrets, variables, config, emailService }) => { const betterAuthSecret = (await variables.get('BETTER_AUTH_SECRET')) || await secrets.getSecret('BETTER_AUTH_SECRET').catch(() => DEV_BETTER_AUTH_SECRET) if (process.env.NODE_ENV === 'production' && betterAuthSecret === DEV_BETTER_AUTH_SECRET) { throw new Error('BETTER_AUTH_SECRET must be configured in production') } // Genuinely optional: unset simply disables /api/auth/sign-in/actor (scenarios // off for this deployment) — the actor plugin refuses all sign-ins without it. const SCENARIO_ACTOR_SECRET = await secrets .getSecret('SCENARIO_ACTOR_SECRET') .catch(() => undefined) // Fabric operator admin: the RSA public key the control plane's token is // verified against. The Fabric deployer pushes FABRIC_AUTH_PUBLIC_KEY onto // every stage; locally it's simply absent, which disables /sign-in/fabric. const FABRIC_AUTH_PUBLIC_KEY = await variables.get('FABRIC_AUTH_PUBLIC_KEY') return betterAuth({ secret: betterAuthSecret, baseURL: getOrigin(config.domain), trustedOrigins: [ 'http://localhost:3001', 'http://localhost:3000', 'http://127.0.0.1:3001', 'http://127.0.0.1:3000', `https://${config.domain}`, `https://api.${config.domain}`, // Fabric deploys every runtime to a *.pikkufabric.dev subdomain; the // request Origin is that dynamic host, so trust it or auth 403s // INVALID_ORIGIN (baseURL is pinned to the real api.). 'https://*.pikkufabric.dev', ], advanced: { database: { generateId: 'uuid', }, crossSubDomainCookies: { enabled: !isLocalDomain(config.domain), domain: isLocalDomain(config.domain) ? undefined : config.domain, }, }, database: { db: kysely as any, type: 'sqlite', }, user: { modelName: 'authUser', fields: { emailVerified: 'emailVerified', createdAt: 'createdAt', updatedAt: 'updatedAt', }, additionalFields: { role: { type: 'string', required: true, defaultValue: UserRoles.OWNER, input: true, }, type: { type: 'string', required: true, defaultValue: UserTypes.CANDIDATE, input: true, }, }, }, session: { modelName: 'authSession', // Signed session cookie cache: lets the global middleware resolve the // session statelessly (betterAuthStatelessSession) without services.auth() // — which is tree-shaken off the RPC bundle on the deployed CF Worker, so // the full-server betterAuthSession 500s there. cookieCache: { enabled: true, maxAge: 5 * 60 }, fields: { expiresAt: 'expiresAt', createdAt: 'createdAt', updatedAt: 'updatedAt', ipAddress: 'ipAddress', userAgent: 'userAgent', userId: 'userId', }, }, account: { modelName: 'authAccount', fields: { accountId: 'accountId', providerId: 'providerId', userId: 'userId', accessToken: 'accessToken', refreshToken: 'refreshToken', idToken: 'idToken', accessTokenExpiresAt: 'accessTokenExpiresAt', refreshTokenExpiresAt: 'refreshTokenExpiresAt', createdAt: 'createdAt', updatedAt: 'updatedAt', }, }, verification: { modelName: 'authVerification', fields: { expiresAt: 'expiresAt', createdAt: 'createdAt', updatedAt: 'updatedAt', }, }, emailAndPassword: { enabled: true, }, emailVerification: { sendVerificationEmail: async ({ user, url }) => { await emailService?.send({ to: user.email, template: { name: 'candidate-double-opt-in', locale: 'en', data: { firstname: user.name, verificationLink: url, }, }, }) }, }, // admin(): list/setRole/ban/impersonate users (db/sqlite/0005-admin.sql). // actor(): synthetic scenario users (db/sqlite/0004-user-actor.sql). // fabric(): Fabric console operator sign-in (db/sqlite/0006-fabric.sql). plugins: [ actor({ secret: SCENARIO_ACTOR_SECRET }), admin(), fabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY }), magicLink({ disableSignUp: true, sendMagicLink: async ({ email: to, token, metadata }) => { const user = await kysely .selectFrom('authUser') .select(['id', 'name']) .where('email', '=', to) .executeTakeFirst() const name = user?.name || to.split('@')[0] const templateName = getMagicLinkTemplateName(metadata?.template) const magicLink = buildMagicLink(config.domain, token) await emailService?.send({ to, template: { name: templateName, locale: 'en', data: { firstname: name, magicLink, }, }, }) }, }), ], }) }) const buildMagicLink = (domain: string, token: string) => { const host = isLocalDomain(domain) ? `http://${domain}` : `https://${domain}` return `${host}/auth/magic-link?token=${token}&redirect=/jobs/application` } const MAGIC_LINK_TEMPLATE_NAMES = new Set([ 'candidate-magic-link', 'candidate-qualified', 'candidate-invalid-documents', 'candidate-process-incomplete', ]) const getMagicLinkTemplateName = (template: unknown): EmailTemplateName => { if (typeof template === 'string' && MAGIC_LINK_TEMPLATE_NAMES.has(template as EmailTemplateName)) { return template as EmailTemplateName } return 'candidate-magic-link' }