/** * Internal Better Auth instance — lazily created, not exported to templates. * * Templates interact with auth via the existing `getSession()`, `autoMountAuth()`, * `createAuthPlugin()`, and `createGoogleAuthPlugin()` APIs. Better Auth is an * implementation detail behind those interfaces. */ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { betterAuth, type BetterAuthOptions } from "better-auth"; import { bearer } from "better-auth/plugins/bearer"; import { jwt } from "better-auth/plugins/jwt"; import { pgTable, text as pgText, timestamp as pgTimestamp, boolean as pgBoolean, } from "drizzle-orm/pg-core"; import { sqliteTable, text as sqliteText, integer as sqliteInteger, } from "drizzle-orm/sqlite-core"; import { TEMPLATES } from "../cli/templates-meta.js"; import { getDbExec, isPostgres } from "../db/client.js"; import { getDialect, getDatabaseUrl, getDatabaseAuthToken, closePgliteClients, getPgliteClient, isPgliteUrl, loadPgliteDrizzle, pgPoolOptions, neonPoolMax, attachNeonPoolErrorLogger, } from "../db/client.js"; import { ensureTableExists } from "../db/ddl-guard.js"; import { saveOAuthTokens } from "../oauth-tokens/store.js"; import { acceptPendingInvitationsForEmail } from "../org/accept-pending.js"; import { autoJoinDomainMatchingOrgs } from "../org/auto-join-domain.js"; import { flushTracking, identify, track } from "../tracking/index.js"; import { getAppProductionUrl } from "./app-url.js"; import { signupAttributionFromCookieHeader } from "./attribution.js"; import { resolveAuthCookieNamespace } from "./cookie-namespace.js"; import { getWorkspaceA2ADerivedSecret } from "./derived-secret.js"; import { renderResetPasswordEmail, renderVerifySignupEmail, } from "./email-templates.js"; import { sendEmail, isEmailConfigured } from "./email.js"; import { resolveGoogleSignInCredentials } from "./google-oauth-credentials.js"; async function flushSignupTracking(): Promise { try { await Promise.race([ flushTracking(), new Promise((resolve) => setTimeout(resolve, 1500)), ]); } catch { // Signup should never fail because analytics delivery did. } } export async function hasBetterAuthUserEmail(email: string): Promise { const adapter = await getBetterAuthInternalAdapter().catch(() => undefined); if (!adapter) return false; const existing = await adapter .findUserByEmail(email, { includeAccounts: false }) .catch(() => null); return !!existing?.user?.email; } export async function trackSignupEvent({ authProvider, authUserId, email, name, attribution, }: { authProvider: string; authUserId?: string; email: string; name?: string | null; /** * First-touch referral attribution derived from the visitor's `an_ft` * cookie (see `server/attribution.ts`). Snake_case keys such as * `referral_source`, `referrer_user`, and the UTM passthrough are merged * into the `signup` event so we can measure where new users came from. * `undefined` values are dropped; a missing object is a clean no-op. */ attribution?: Record; }): Promise { identify(email, { email, name: name ?? undefined, authUserId, }); const cleanAttribution: Record = {}; if (attribution) { for (const [key, value] of Object.entries(attribution)) { if (typeof value === "string" && value.length > 0) { cleanAttribution[key] = value; } } } track( "signup", { ...resolveSignupTrackingProperties(), auth_provider: authProvider, ...(authUserId ? { auth_user_id: authUserId } : {}), ...cleanAttribution, }, { userId: email }, ); await flushSignupTracking(); } // --------------------------------------------------------------------------- // Persistent auth secret // --------------------------------------------------------------------------- let inMemoryDevAuthSecret: string | undefined; /** * Resolve the Better Auth signing secret. * * Resolution order: * 1. `BETTER_AUTH_SECRET` env var — explicit, recommended for prod. * 2. Hosted workspace deploys can derive a per-purpose secret from the * already-required `A2A_SECRET` root. This keeps fresh workspace branches * bootable without reusing the raw A2A key as a cookie-signing key. * 3. Existing `.env.local` values in the template cwd — read-only * compatibility for projects that already configured this secret. * 4. Generate a per-process in-memory random 32-byte hex in development. * * Why this matters: before this helper existed, missing `BETTER_AUTH_SECRET` * fell through to `GOOGLE_CLIENT_SECRET` / `ACCESS_TOKEN` / a hardcoded * string. If a template happened to have none of those, each dev-server * boot would re-fall back to the hardcoded value (still stable) — but * rotating Google credentials, toggling `ACCESS_TOKEN`, or churning the * fallback chain would invalidate every signed cookie and force everyone * to sign in again. We still read explicit env configuration, but never * auto-write a generated secret into env files. */ function resolveAuthSecret(): string { if (process.env.BETTER_AUTH_SECRET) return process.env.BETTER_AUTH_SECRET; const workspaceDerivedSecret = getWorkspaceA2ADerivedSecret("better-auth"); if (workspaceDerivedSecret) return workspaceDerivedSecret; // In production, beyond the workspace A2A-derived fallback above, never // auto-generate or use legacy fallbacks. A generated secret invalidates every // signed session cookie on the next cold start (serverless filesystems // aren't persistent), and the legacy hardcoded fallback is identical across // every deploy that hits it — both are serious enough to fail the boot loudly // so the deployer notices. if (process.env.NODE_ENV === "production") { const sample = crypto.randomBytes(32).toString("hex"); throw new Error( "[agent-native] BETTER_AUTH_SECRET is not set. This is required in production " + "so signed session cookies stay valid across deploys. Set it as a deploy " + "environment variable (any 32-byte hex string), e.g.:\n\n" + ` BETTER_AUTH_SECRET=${sample}\n\n` + "Generate your own with `openssl rand -hex 32`. If you already have a " + "running deploy and need to preserve existing sessions, set it to your " + "previously-deployed BETTER_AUTH_SECRET value first, then rotate to a " + "fresh one. Hosted workspace deploys may also " + "set A2A_SECRET; agent-native derives a per-purpose Better Auth secret " + "from that workspace root secret.", ); } // SECURITY (audit 09 LOW-2): the previous fallback chain // (`GOOGLE_CLIENT_SECRET || ACCESS_TOKEN || hardcoded`) reused // cross-purpose secrets and a public hardcoded literal as the cookie // HMAC. Dropped entirely — better to mint an ephemeral secret than to // re-use a Google client secret or a known string. const existing = readEnvLocalSecret( path.resolve(process.cwd(), ".env.local"), ); if (existing) return existing; if (!inMemoryDevAuthSecret) { inMemoryDevAuthSecret = crypto.randomBytes(32).toString("hex"); console.warn( "[agent-native] BETTER_AUTH_SECRET is not configured. Using an ephemeral " + "in-memory development secret. Sessions will reset every time this " + "process restarts. Set BETTER_AUTH_SECRET in your environment to keep " + "sessions valid across restarts.", ); } return inMemoryDevAuthSecret; } function readEnvLocalSecret(envLocalPath: string): string | undefined { try { const content = fs.readFileSync(envLocalPath, "utf8"); // Match `BETTER_AUTH_SECRET=...` on its own line. Tolerate optional // quotes and leading `export `. Stop at the first newline or quote. const m = content.match( /^(?:export\s+)?BETTER_AUTH_SECRET\s*=\s*"?([^"\r\n]+)"?\s*$/m, ); return m?.[1]?.trim() || undefined; } catch { return undefined; } } function normalizeTrackingSlug(value: string | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; const unscoped = trimmed.startsWith("@") ? (trimmed.split("/").pop() ?? trimmed) : trimmed; const slug = unscoped .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return slug || undefined; } function knownTemplateSlug(value: string | undefined): string | undefined { const slug = normalizeTrackingSlug(value); if (!slug) return undefined; const withoutPrefix = slug.startsWith("agent-native-") ? slug.slice("agent-native-".length) : slug; return TEMPLATES.some((template) => template.name === withoutPrefix) ? withoutPrefix : undefined; } function readPackageName(): string | undefined { try { const pkgPath = path.join(process.cwd(), "package.json"); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { name?: string; }; return pkg.name; } catch { return undefined; } } function appSlugFromUrl(value: string | undefined): string | undefined { if (!value?.trim()) return undefined; try { const raw = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`; const hostname = new URL(raw).hostname.toLowerCase(); if (hostname.endsWith(".agent-native.com")) { return normalizeTrackingSlug( hostname.slice(0, -".agent-native.com".length), ); } return normalizeTrackingSlug(hostname.split(".")[0]); } catch { return undefined; } } /** @internal */ export function resolveSignupTrackingIdentity(): { app?: string; template?: string; } { const explicitApp = normalizeTrackingSlug(process.env.AGENT_NATIVE_APP) || normalizeTrackingSlug(process.env.VITE_AGENT_NATIVE_APP); const packageApp = normalizeTrackingSlug(process.env.npm_package_name) || normalizeTrackingSlug(readPackageName()); const urlApp = appSlugFromUrl(process.env.APP_URL) || appSlugFromUrl(process.env.BETTER_AUTH_URL) || appSlugFromUrl(process.env.URL) || appSlugFromUrl(process.env.DEPLOY_URL) || appSlugFromUrl(process.env.VERCEL_PROJECT_PRODUCTION_URL) || appSlugFromUrl(process.env.VERCEL_URL); const app = explicitApp || urlApp || packageApp || normalizeTrackingSlug(process.env.APP_NAME); const template = knownTemplateSlug(process.env.AGENT_NATIVE_TEMPLATE) || knownTemplateSlug(process.env.VITE_AGENT_NATIVE_TEMPLATE) || knownTemplateSlug(process.env.APP_TEMPLATE) || knownTemplateSlug(process.env.VITE_APP_TEMPLATE) || knownTemplateSlug(app) || knownTemplateSlug(packageApp) || knownTemplateSlug(urlApp); return { ...(app ? { app } : {}), ...(template ? { template } : {}), }; } /** @internal */ export function resolveSignupTrackingProperties(): Record { const identity = resolveSignupTrackingIdentity(); return { ...identity, ...(identity.app ? { agent_native_app: identity.app } : {}), ...(identity.template ? { agent_native_template: identity.template } : {}), }; } export function shouldSkipEmailVerification(): boolean { const value = process.env.AUTH_SKIP_EMAIL_VERIFICATION; if (value == null) { const deployContext = process.env.AGENT_NATIVE_BUILD_DEPLOY_CONTEXT || process.env.CONTEXT; return ( process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" || deployContext === "deploy-preview" ); } const normalized = value.trim().toLowerCase(); return normalized !== "" && normalized !== "0" && normalized !== "false"; } /** Read-only accessor for the resolved auth secret. */ export function getAuthSecret(): string { return resolveAuthSecret(); } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** The shape we need from a Better Auth instance (internal — not exported to templates). */ export interface BetterAuthInstance { handler: (request: Request) => Promise; api: { getSession: (opts: { headers: Headers }) => Promise<{ user: { id: string; email: string; name: string }; session: { id: string; token: string; expiresAt: Date; }; } | null>; signInEmail: (opts: { body: { email: string; password: string }; }) => Promise<{ token?: string; user?: any } | null>; signUpEmail: (opts: { body: { email: string; password: string; name: string; callbackURL?: string; }; headers?: Headers; }) => Promise; signOut: (opts: { headers: Headers }) => Promise; }; } export interface BetterAuthConfig { /** Base path for Better Auth routes. Default: "/_agent-native/auth/ba" */ basePath?: string; /** Additional social providers beyond what env vars auto-detect */ socialProviders?: BetterAuthOptions["socialProviders"]; /** Additional Better Auth plugins */ plugins?: BetterAuthOptions["plugins"]; /** * Additional Google OAuth scopes (Gmail, Calendar, etc.) to request * up front during the primary "Sign in with Google" flow, beyond the * default identity scopes (`openid`, `email`, `profile`). * * When set, the Google social provider also opts into: * - `accessType: "offline"` — so a refresh token is issued * - `prompt: "consent"` — so the refresh token is reissued every sign-in * * Tokens are mirrored into `oauth_tokens` via a databaseHooks.account * hook so existing template code that reads from `oauth_tokens` (mail's * Gmail client, calendar's events fetcher) works without any separate * "Connect Google" page. */ googleScopes?: string[]; } // --------------------------------------------------------------------------- // Lazy instance // --------------------------------------------------------------------------- let _auth: BetterAuthInstance | undefined; let _initPromise: Promise | undefined; // Track the Neon serverless Pool we open for Better Auth so closeBetterAuth() // can release it. The Pool keeps WebSocket connections open; leaking them on // hot-reload or process restart exhausts Neon's connection slot budget. let _neonAuthPool: any; const pgAuthSchema = { user: pgTable("user", { id: pgText("id").primaryKey(), name: pgText("name").notNull(), email: pgText("email").notNull().unique(), emailVerified: pgBoolean("email_verified").notNull().default(false), image: pgText("image"), createdAt: pgTimestamp("created_at", { withTimezone: true }).notNull(), updatedAt: pgTimestamp("updated_at", { withTimezone: true }).notNull(), }), session: pgTable("session", { id: pgText("id").primaryKey(), expiresAt: pgTimestamp("expires_at", { withTimezone: true }).notNull(), token: pgText("token").notNull().unique(), createdAt: pgTimestamp("created_at", { withTimezone: true }).notNull(), updatedAt: pgTimestamp("updated_at", { withTimezone: true }).notNull(), ipAddress: pgText("ip_address"), userAgent: pgText("user_agent"), userId: pgText("user_id").notNull(), activeOrganizationId: pgText("active_organization_id"), }), account: pgTable("account", { id: pgText("id").primaryKey(), accountId: pgText("account_id").notNull(), providerId: pgText("provider_id").notNull(), userId: pgText("user_id").notNull(), accessToken: pgText("access_token"), refreshToken: pgText("refresh_token"), idToken: pgText("id_token"), accessTokenExpiresAt: pgTimestamp("access_token_expires_at", { withTimezone: true, }), refreshTokenExpiresAt: pgTimestamp("refresh_token_expires_at", { withTimezone: true, }), scope: pgText("scope"), password: pgText("password"), createdAt: pgTimestamp("created_at", { withTimezone: true }).notNull(), updatedAt: pgTimestamp("updated_at", { withTimezone: true }).notNull(), }), verification: pgTable("verification", { id: pgText("id").primaryKey(), identifier: pgText("identifier").notNull(), value: pgText("value").notNull(), expiresAt: pgTimestamp("expires_at", { withTimezone: true }).notNull(), createdAt: pgTimestamp("created_at", { withTimezone: true }).notNull(), updatedAt: pgTimestamp("updated_at", { withTimezone: true }).notNull(), }), organization: pgTable("organization", { id: pgText("id").primaryKey(), name: pgText("name").notNull(), slug: pgText("slug").notNull().unique(), logo: pgText("logo"), metadata: pgText("metadata"), createdAt: pgTimestamp("created_at", { withTimezone: true }).notNull(), updatedAt: pgTimestamp("updated_at", { withTimezone: true }).notNull(), }), member: pgTable("member", { id: pgText("id").primaryKey(), organizationId: pgText("organization_id").notNull(), userId: pgText("user_id").notNull(), role: pgText("role").notNull().default("member"), createdAt: pgTimestamp("created_at", { withTimezone: true }).notNull(), updatedAt: pgTimestamp("updated_at", { withTimezone: true }).notNull(), }), invitation: pgTable("invitation", { id: pgText("id").primaryKey(), organizationId: pgText("organization_id").notNull(), email: pgText("email").notNull(), role: pgText("role"), status: pgText("status").notNull().default("pending"), expiresAt: pgTimestamp("expires_at", { withTimezone: true }).notNull(), inviterId: pgText("inviter_id").notNull(), createdAt: pgTimestamp("created_at", { withTimezone: true }).notNull(), updatedAt: pgTimestamp("updated_at", { withTimezone: true }).notNull(), }), jwks: pgTable("jwks", { id: pgText("id").primaryKey(), publicKey: pgText("public_key").notNull(), privateKey: pgText("private_key").notNull(), createdAt: pgTimestamp("created_at", { withTimezone: true }).notNull(), expiresAt: pgTimestamp("expires_at", { withTimezone: true }), }), }; const sqliteAuthSchema = { user: sqliteTable("user", { id: sqliteText("id").primaryKey(), name: sqliteText("name").notNull(), email: sqliteText("email").notNull().unique(), emailVerified: sqliteInteger("email_verified", { mode: "boolean" }) .notNull() .default(false), image: sqliteText("image"), createdAt: sqliteInteger("created_at", { mode: "timestamp_ms" }).notNull(), updatedAt: sqliteInteger("updated_at", { mode: "timestamp_ms" }).notNull(), }), session: sqliteTable("session", { id: sqliteText("id").primaryKey(), expiresAt: sqliteInteger("expires_at", { mode: "timestamp_ms" }).notNull(), token: sqliteText("token").notNull().unique(), createdAt: sqliteInteger("created_at", { mode: "timestamp_ms" }).notNull(), updatedAt: sqliteInteger("updated_at", { mode: "timestamp_ms" }).notNull(), ipAddress: sqliteText("ip_address"), userAgent: sqliteText("user_agent"), userId: sqliteText("user_id").notNull(), activeOrganizationId: sqliteText("active_organization_id"), }), account: sqliteTable("account", { id: sqliteText("id").primaryKey(), accountId: sqliteText("account_id").notNull(), providerId: sqliteText("provider_id").notNull(), userId: sqliteText("user_id").notNull(), accessToken: sqliteText("access_token"), refreshToken: sqliteText("refresh_token"), idToken: sqliteText("id_token"), accessTokenExpiresAt: sqliteInteger("access_token_expires_at", { mode: "timestamp_ms", }), refreshTokenExpiresAt: sqliteInteger("refresh_token_expires_at", { mode: "timestamp_ms", }), scope: sqliteText("scope"), password: sqliteText("password"), createdAt: sqliteInteger("created_at", { mode: "timestamp_ms" }).notNull(), updatedAt: sqliteInteger("updated_at", { mode: "timestamp_ms" }).notNull(), }), verification: sqliteTable("verification", { id: sqliteText("id").primaryKey(), identifier: sqliteText("identifier").notNull(), value: sqliteText("value").notNull(), expiresAt: sqliteInteger("expires_at", { mode: "timestamp_ms" }).notNull(), createdAt: sqliteInteger("created_at", { mode: "timestamp_ms" }).notNull(), updatedAt: sqliteInteger("updated_at", { mode: "timestamp_ms" }).notNull(), }), organization: sqliteTable("organization", { id: sqliteText("id").primaryKey(), name: sqliteText("name").notNull(), slug: sqliteText("slug").notNull().unique(), logo: sqliteText("logo"), metadata: sqliteText("metadata"), createdAt: sqliteInteger("created_at", { mode: "timestamp_ms" }).notNull(), updatedAt: sqliteInteger("updated_at", { mode: "timestamp_ms" }).notNull(), }), member: sqliteTable("member", { id: sqliteText("id").primaryKey(), organizationId: sqliteText("organization_id").notNull(), userId: sqliteText("user_id").notNull(), role: sqliteText("role").notNull().default("member"), createdAt: sqliteInteger("created_at", { mode: "timestamp_ms" }).notNull(), updatedAt: sqliteInteger("updated_at", { mode: "timestamp_ms" }).notNull(), }), invitation: sqliteTable("invitation", { id: sqliteText("id").primaryKey(), organizationId: sqliteText("organization_id").notNull(), email: sqliteText("email").notNull(), role: sqliteText("role"), status: sqliteText("status").notNull().default("pending"), expiresAt: sqliteInteger("expires_at", { mode: "timestamp_ms" }).notNull(), inviterId: sqliteText("inviter_id").notNull(), createdAt: sqliteInteger("created_at", { mode: "timestamp_ms" }).notNull(), updatedAt: sqliteInteger("updated_at", { mode: "timestamp_ms" }).notNull(), }), jwks: sqliteTable("jwks", { id: sqliteText("id").primaryKey(), publicKey: sqliteText("public_key").notNull(), privateKey: sqliteText("private_key").notNull(), createdAt: sqliteInteger("created_at", { mode: "timestamp_ms" }).notNull(), expiresAt: sqliteInteger("expires_at", { mode: "timestamp_ms" }), }), }; /** * Mirror a Better Auth `account` row for Google into the `oauth_tokens` * table that template code (mail's Gmail client, calendar's events fetcher) * reads from. Called from the `databaseHooks.account.create.after` and * `.update.after` hooks so tokens captured during the primary "Sign in * with Google" flow flow straight to the apps that need them — no * separate "Connect Google" page required. * * Resolves `account.userId` to the user's email by querying the `user` * table (Better Auth always quotes "user" because it's a reserved word * in Postgres; SQLite accepts the quotes too). * * The hook is fire-and-forget from the caller's perspective — every * failure is caught upstream so a flake in `oauth_tokens` never blocks * sign-in. We still no-op on missing fields here as a defense in depth. */ async function mirrorGoogleAccountToOAuthTokens(account: { providerId?: string; userId?: string; accountId?: string; accessToken?: string | null; refreshToken?: string | null; accessTokenExpiresAt?: Date | string | number | null; scope?: string | null; idToken?: string | null; }): Promise { if (!account || account.providerId !== "google") return; if (!account.userId) return; const accessToken = account.accessToken ?? undefined; if (!accessToken) { // Better Auth sometimes upserts an account row before tokens are // attached (e.g. linking flows). Nothing to mirror yet — the next // update hook will run once the access token lands. return; } // Resolve user email from userId. const db = getDbExec(); let email: string | undefined; try { const { rows } = await db.execute({ sql: 'SELECT email FROM "user" WHERE id = ?', args: [account.userId], }); email = (rows[0]?.email as string | undefined) ?? undefined; } catch (err) { console.error( "[auth] mirror Google tokens: failed to resolve user email from userId", err, ); return; } if (!email) return; // Normalise expiry to epoch ms (Google's "expiry_date" convention used // throughout the templates). let expiryDate: number | undefined; const raw = account.accessTokenExpiresAt; if (raw instanceof Date) { expiryDate = raw.getTime(); } else if (typeof raw === "number") { expiryDate = raw; } else if (typeof raw === "string") { const ms = Date.parse(raw); expiryDate = Number.isFinite(ms) ? ms : undefined; } const tokens: Record = { access_token: accessToken, token_type: "Bearer", }; if (account.refreshToken) tokens.refresh_token = account.refreshToken; if (expiryDate) tokens.expiry_date = expiryDate; if (account.scope) tokens.scope = account.scope; if (account.idToken) tokens.id_token = account.idToken; await saveOAuthTokens("google", email, tokens, email); } async function ensureBetterAuthTables(): Promise { const db = getDbExec(); // PG guard: probe information_schema first (no lock) for each table; run // DDL only when missing, bounded by a transaction-scoped lock_timeout. // Probe names are UNQUOTED (what information_schema.tables.table_name stores); // createSql keeps the QUOTED "user"/"session"/… form required by Postgres. if (isPostgres()) { const pgTables: Array<[name: string, createSql: string]> = [ [ "user", `CREATE TABLE IF NOT EXISTS "user" (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, email_verified BOOLEAN NOT NULL DEFAULT FALSE, image TEXT, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL)`, ], [ "session", `CREATE TABLE IF NOT EXISTS "session" (id TEXT PRIMARY KEY, expires_at TIMESTAMPTZ NOT NULL, token TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, ip_address TEXT, user_agent TEXT, user_id TEXT NOT NULL, active_organization_id TEXT)`, ], [ "account", `CREATE TABLE IF NOT EXISTS "account" (id TEXT PRIMARY KEY, account_id TEXT NOT NULL, provider_id TEXT NOT NULL, user_id TEXT NOT NULL, access_token TEXT, refresh_token TEXT, id_token TEXT, access_token_expires_at TIMESTAMPTZ, refresh_token_expires_at TIMESTAMPTZ, scope TEXT, password TEXT, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL)`, ], [ "verification", `CREATE TABLE IF NOT EXISTS "verification" (id TEXT PRIMARY KEY, identifier TEXT NOT NULL, value TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL)`, ], [ "organization", `CREATE TABLE IF NOT EXISTS "organization" (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, logo TEXT, metadata TEXT, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL)`, ], [ "member", `CREATE TABLE IF NOT EXISTS "member" (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, user_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL)`, ], [ "invitation", `CREATE TABLE IF NOT EXISTS "invitation" (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, email TEXT NOT NULL, role TEXT, status TEXT NOT NULL DEFAULT 'pending', expires_at TIMESTAMPTZ NOT NULL, inviter_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL)`, ], [ "jwks", `CREATE TABLE IF NOT EXISTS "jwks" (id TEXT PRIMARY KEY, public_key TEXT NOT NULL, private_key TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ)`, ], ]; for (const [name, sql] of pgTables) await ensureTableExists(name, sql); return; } // SQLite (local dev): no lock problem — keep the original behaviour. const sqliteStatements = [ `CREATE TABLE IF NOT EXISTS user (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, email_verified INTEGER NOT NULL DEFAULT 0, image TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, token TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, ip_address TEXT, user_agent TEXT, user_id TEXT NOT NULL, active_organization_id TEXT)`, `CREATE TABLE IF NOT EXISTS account (id TEXT PRIMARY KEY, account_id TEXT NOT NULL, provider_id TEXT NOT NULL, user_id TEXT NOT NULL, access_token TEXT, refresh_token TEXT, id_token TEXT, access_token_expires_at INTEGER, refresh_token_expires_at INTEGER, scope TEXT, password TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS verification (id TEXT PRIMARY KEY, identifier TEXT NOT NULL, value TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS organization (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, logo TEXT, metadata TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS member (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, user_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS invitation (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, email TEXT NOT NULL, role TEXT, status TEXT NOT NULL DEFAULT 'pending', expires_at INTEGER NOT NULL, inviter_id TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS jwks (id TEXT PRIMARY KEY, public_key TEXT NOT NULL, private_key TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER)`, ]; for (const sql of sqliteStatements) await db.execute(sql); } /** * Get or create the Better Auth instance. * Lazily initialized on first call — the database must be reachable by then. */ export async function getBetterAuth( config?: BetterAuthConfig, ): Promise { if (_auth) return _auth; if (_initPromise) return _initPromise; _initPromise = createBetterAuthInstance(config); _auth = await _initPromise; return _auth; } /** * Synchronous getter — returns the instance if already initialized, else undefined. * Use this in hot paths where you know init has already happened. */ export function getBetterAuthSync(): BetterAuthInstance | undefined { return _auth; } /** * The subset of Better Auth's internal adapter we use for federated-SSO * JIT account linking. Better Auth owns these writes (id + timestamp + * schema handling), so callers never hand-roll SQL against `user`/`account`. * Read-only lookups plus account-linking and profile-update operations used by * shared Core surfaces. Better Auth owns the actual identity writes. */ export interface BetterAuthInternalAdapter { findUserByEmail: ( email: string, options?: { includeAccounts: boolean }, ) => Promise<{ user: { id: string; email: string; name?: string }; accounts: Array<{ providerId: string; accountId: string }>; } | null>; linkAccount: (account: { userId: string; providerId: string; accountId: string; }) => Promise; createUser: (user: { email: string; name: string; emailVerified?: boolean; }) => Promise<{ id: string }>; /** Optional because older/custom adapter shapes may not expose mutations. */ updateUser?: ( userId: string, data: { name?: string; image?: string | null }, ) => Promise; } /** * Resolve Better Auth's internal adapter via the live instance's * `$context`. The framework's narrowed `BetterAuthInstance` interface omits * `$context`, but the underlying object created by `betterAuth(...)` always * exposes it (see Better Auth's `Auth` type) — so this is a safe, typed * accessor for the federated-SSO client. Returns `undefined` if the context * shape is unexpected (older/newer Better Auth) so callers can fall back. */ export async function getBetterAuthInternalAdapter( config?: BetterAuthConfig, ): Promise { const auth = (await getBetterAuth(config)) as unknown as { $context?: Promise<{ internalAdapter?: BetterAuthInternalAdapter }>; }; try { const ctx = await auth.$context; const ia = ctx?.internalAdapter; if ( ia && typeof ia.findUserByEmail === "function" && typeof ia.linkAccount === "function" && typeof ia.createUser === "function" ) { return ia; } } catch { // Context resolution failed — caller falls back to the signup path. } return undefined; } /** Reset for testing */ export async function resetBetterAuth(): Promise { _auth = undefined; _initPromise = undefined; if (_neonAuthPool) { try { await _neonAuthPool.end(); } catch { // Pool may have already closed (process exiting, etc.) — don't block reset. } _neonAuthPool = undefined; } await closePgliteClients(); } // --------------------------------------------------------------------------- // Instance creation // --------------------------------------------------------------------------- async function createBetterAuthInstance( config?: BetterAuthConfig, ): Promise { const dialect = getDialect(); const basePath = config?.basePath ?? "/_agent-native/auth/ba"; await ensureBetterAuthTables(); // Build social providers from env vars const socialProviders: BetterAuthOptions["socialProviders"] = { ...config?.socialProviders, }; const extraScopes = config?.googleScopes ?? []; const googleCredentials = extraScopes.length > 0 ? process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET ? { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, } : null : resolveGoogleSignInCredentials(); if (googleCredentials) { // When the template requests broader scopes (Gmail, Calendar, etc.) // ask for them on the primary sign-in flow so a separate "Connect // Google" round-trip isn't needed. `accessType: "offline"` plus // `prompt: "consent"` ensures we always receive a refresh token back — // Google only re-issues a refresh token on consent, so re-signing in // (e.g. after switching machines) would otherwise leave us with an // access token that can't be refreshed. const baseScopes = ["openid", "email", "profile"]; const mergedScopes = Array.from(new Set([...baseScopes, ...extraScopes])); socialProviders.google = { clientId: googleCredentials.clientId, clientSecret: googleCredentials.clientSecret, ...(extraScopes.length > 0 ? { scope: mergedScopes, accessType: "offline" as const, prompt: "consent" as const, } : {}), }; } if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { socialProviders.github = { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }; } // Build database config const database = await buildDatabaseConfig(dialect); const secret = resolveAuthSecret(); const appUrl = getAppProductionUrl(); const cookieNamespace = resolveAuthCookieNamespace(); const requireEmailVerification = (await isEmailConfigured()) && !shouldSkipEmailVerification(); const shouldMirrorGoogleAccountTokens = (config?.googleScopes?.length ?? 0) > 0; const auth = betterAuth({ basePath, baseURL: appUrl, database, secret, emailAndPassword: { enabled: true, minPasswordLength: 8, // Only require email verification when an email provider is configured. // Without a provider, verification emails can't be sent, so requiring // verification would lock users out of signup entirely. Local dev/test // skip verification by default so +qa accounts can be created quickly; // hosted QA deployments can opt out with AUTH_SKIP_EMAIL_VERIFICATION=1. requireEmailVerification, sendResetPassword: async ({ user, token }) => { // APP_BASE_PATH lets this app mount under a prefix (e.g. /mail). The // reset link must include that prefix so the page resolves correctly. const appBasePath = ( process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH || "" ).replace(/\/$/, ""); const resetUrl = `${appUrl}${appBasePath}/_agent-native/auth/reset?token=${encodeURIComponent(token)}`; const { subject, html, text } = renderResetPasswordEmail({ email: user.email, resetUrl, }); await sendEmail({ to: user.email, subject, html, text }); }, }, emailVerification: { // Fire verification email right after signup, before the user has a // session — pairs with requireEmailVerification above. Only enabled // when an email provider is configured. sendOnSignUp: requireEmailVerification, // Auto-create a session once the user clicks the link. Without this, // verified users would have to go back and sign in manually, which is // a confusing dead-end on the verify screen. autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { // APP_BASE_PATH lets this app mount under a prefix (e.g. /mail). The // verification link must include that prefix so the page resolves correctly. const verifyBasePath = ( process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH || "" ).replace(/\/$/, ""); const verifyUrl = verifyBasePath ? url.replace(/(\/\/[^/]+)(\/)/, `$1${verifyBasePath}$2`) : url; const { subject, html, text } = renderVerifySignupEmail({ email: user.email, verifyUrl, }); await sendEmail({ to: user.email, subject, html, text }); }, }, socialProviders, account: { // Merge accounts when a user signs in with a social provider using an // email that already has a local email/password account (or vice versa). // Only providers listed in `trustedProviders` auto-link — these are the // ones that verify emails at the identity layer. Never add a provider // here that lets users claim an unverified email; that would be an // account-takeover vector. accountLinking: { enabled: true, trustedProviders: ["google", "github"], }, }, databaseHooks: { user: { create: { after: async ( user: { id?: string; email?: string; name?: string | null; }, // Better Auth (1.6.x) passes the endpoint context as the 2nd arg. // It carries the originating request's headers (and on OAuth // signups the callback request's headers), which is where the // browser's `an_ft` first-touch cookie rides in. context?: { headers?: Headers | null; request?: { headers?: Headers | null } | null; } | null, ) => { // When a newly-created user's email has pending org invitations // (common when someone is invited *before* they've signed up), // auto-accept them so the user lands in the org on their very // first page load instead of a blank-slate workspace. const email = user?.email; if (!email) return; // Derive first-touch referral attribution from the request's // cookie header. Never let attribution parsing throw or block // signup — on any error fall back to `direct`. let attribution: Record | undefined; try { const cookieHeader = context?.headers?.get("cookie") ?? context?.request?.headers?.get("cookie") ?? null; attribution = signupAttributionFromCookieHeader(cookieHeader); } catch (err) { console.error("[auth] failed to derive signup attribution", err); attribution = undefined; } await trackSignupEvent({ authProvider: "better-auth", authUserId: user.id, email, name: user.name, attribution, }); try { await acceptPendingInvitationsForEmail(email); } catch (err) { // Never block signup on invite bookkeeping — log and continue. console.error( "[auth] failed to auto-accept pending invitations", err, ); } try { // Auto-join orgs whose `allowed_domain` matches this email // domain. Lets a fresh `@builder.io` (or any org-domain) // signup land inside the company org on first page load // without going through the picker. No-ops when no match. await autoJoinDomainMatchingOrgs(email); } catch (err) { console.error( "[auth] failed to auto-join domain-matching orgs", err, ); } }, }, }, account: { // Mirror Google account tokens into `oauth_tokens` so existing // template code (mail's Gmail client, calendar's events fetcher) // can pick up Gmail/Calendar credentials from the primary sign-in // flow — no separate "Set up Google" page required. // // Better Auth fires `create` for first-time social sign-in and // `update` whenever a session re-issues tokens (e.g., the user // re-signs in to refresh the token). Both branches do the same // mirroring work; failures never block sign-in. create: { after: async (account: any) => { if (!shouldMirrorGoogleAccountTokens) return; await mirrorGoogleAccountToOAuthTokens(account).catch((err) => { console.error( "[auth] failed to mirror Google account tokens to oauth_tokens (create)", err, ); }); }, }, update: { after: async (account: any) => { if (!shouldMirrorGoogleAccountTokens) return; await mirrorGoogleAccountToOAuthTokens(account).catch((err) => { console.error( "[auth] failed to mirror Google account tokens to oauth_tokens (update)", err, ); }); }, }, }, }, session: { expiresIn: 60 * 60 * 24 * 30, // 30 days updateAge: 60 * 60 * 24, // refresh daily cookieCache: { enabled: true, maxAge: 5 * 60, // 5 min cache }, }, advanced: { cookiePrefix: cookieNamespace.betterAuthCookiePrefix, // Emit `SameSite=None; Secure` when the app is served over HTTPS so // session cookies are delivered inside third-party iframes (e.g. the // Builder.io editor). Plain-HTTP dev keeps the default (Lax) because // `SameSite=None` requires Secure. ...(appUrl.startsWith("https://") ? { defaultCookieAttributes: { sameSite: "none" as const, secure: true, partitioned: true, }, } : {}), // When an effective shared cookie domain is set, share Better Auth's // session cookie across that domain. First-party `*.agent-native.com` // apps intentionally do not use this path because their auth DBs are // separate; Dispatch identity federation handles cross-app sign-in. ...(cookieNamespace.betterAuthCookieDomain ? { crossSubDomainCookies: { enabled: true, domain: cookieNamespace.betterAuthCookieDomain, }, } : {}), }, plugins: [ // JWT: issue tokens for A2A calls, JWKS endpoint for verification jwt({ jwt: { issuer: appUrl, expirationTime: "15m", }, }), // Bearer: accept Bearer tokens on API requests bearer(), ...(config?.plugins ?? []), ], }); return auth as unknown as BetterAuthInstance; } /** * Configure the local auth connection with the same write contention settings * as the shared app connection. Better Auth uses its own SQLite handle, so the * app connection's busy timeout does not protect first-run account creation. */ export function configureLocalSqlite(sqlite: { pragma(statement: string): unknown; }): void { sqlite.pragma("busy_timeout = 10000"); sqlite.pragma("journal_mode = WAL"); } async function buildDatabaseConfig( dialect: string, ): Promise { if (dialect === "postgres") { const url = getDatabaseUrl(); const { buildResilientNeonPool, buildResilientPostgresJsClient, isNeonUrl, } = await import("../db/create-get-db.js"); if (isPgliteUrl(url)) { const { drizzle } = await loadPgliteDrizzle(); const client = await getPgliteClient(url); const db = drizzle({ client, schema: pgAuthSchema }); const { drizzleAdapter } = await import("better-auth/adapters/drizzle"); return drizzleAdapter(db, { provider: "pg", schema: pgAuthSchema, }); } // Neon via @neondatabase/serverless (WebSockets over HTTPS). postgres-js // opens a raw TCP connection on port 5432 which frequently times out on // Netlify Functions / Vercel / CF Workers when Neon's pooler is cold. if (isNeonUrl(url)) { const { Pool } = await import("@neondatabase/serverless"); // Cap the auth pool the same way as the app pool. Better Auth runs a // session lookup on essentially every authenticated request, so an // un-capped pool here is a primary contributor to "Max client // connections reached" across concurrent serverless instances. _neonAuthPool = new Pool({ connectionString: url, max: neonPoolMax(), }); attachNeonPoolErrorLogger(_neonAuthPool, "db/neon-auth"); const { drizzle } = await import("drizzle-orm/neon-serverless"); const db = drizzle(buildResilientNeonPool(_neonAuthPool), { schema: pgAuthSchema, }); const { drizzleAdapter } = await import("better-auth/adapters/drizzle"); return drizzleAdapter(db, { provider: "pg", schema: pgAuthSchema, }); } // Non-Neon Postgres (Supabase, self-hosted, etc.) → postgres-js. // pgPoolOptions caps this pool to a small size on serverless. Better Auth // runs a session lookup on essentially every authenticated request, so an // un-capped pool here is a primary contributor to "Max client connections // reached" across concurrent serverless instances. const { default: postgres } = await import("postgres"); const sql = postgres(url, pgPoolOptions(url)); const { drizzle } = await import("drizzle-orm/postgres-js"); const db = drizzle(buildResilientPostgresJsClient(sql), { schema: pgAuthSchema, }); const { drizzleAdapter } = await import("better-auth/adapters/drizzle"); return drizzleAdapter(db, { provider: "pg", schema: pgAuthSchema, }); } // SQLite / libsql const url = getDatabaseUrl("file:./data/app.db"); if (url.startsWith("file:") || !url.includes("://")) { // Local SQLite via better-sqlite3 const { default: Database } = await import("better-sqlite3"); const filePath = url.replace(/^file:/, ""); const sqlite = new Database(filePath); configureLocalSqlite(sqlite); const { drizzle } = await import("drizzle-orm/better-sqlite3"); const db = drizzle(sqlite, { schema: sqliteAuthSchema }); const { drizzleAdapter } = await import("better-auth/adapters/drizzle"); return drizzleAdapter(db, { provider: "sqlite", schema: sqliteAuthSchema, }); } // Remote libsql (Turso). Use the web client to avoid serverless bundles // depending on libsql's platform-specific native packages. const { createClient } = await import("@libsql/client/web"); const client = createClient({ url, authToken: getDatabaseAuthToken() }); const { drizzle } = await import("drizzle-orm/libsql/web"); const db = drizzle(client, { schema: sqliteAuthSchema }); const { drizzleAdapter } = await import("better-auth/adapters/drizzle"); return drizzleAdapter(db, { provider: "sqlite", schema: sqliteAuthSchema, }); }