Skip to content

Commit 6af5888

Browse files
authored
feat(app-auth): better-auth config factory + guards (#189)
createAppAuth() owns the better-auth setup every product hand-rolls (drizzle adapter over users/sessions/accounts/verifications, Resend reset/verification mail, env-gated GitHub/Google providers, session cookie cache, per-app cookie prefix) and returns the instance plus the createAuthGuard quartet. Optional Tangle SSO wiring composes the existing platform/sso cookie minter so products stop re-implementing better-auth's cookie signing. better-auth becomes an optional peer; only the new ./app-auth subpath imports it.
1 parent cbfffa2 commit 6af5888

5 files changed

Lines changed: 795 additions & 1 deletion

File tree

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@
5353
"import": "./dist/platform/index.js",
5454
"default": "./dist/platform/index.js"
5555
},
56+
"./app-auth": {
57+
"types": "./dist/app-auth/index.d.ts",
58+
"import": "./dist/app-auth/index.js",
59+
"default": "./dist/app-auth/index.js"
60+
},
5661
"./runtime": {
5762
"types": "./dist/runtime/index.d.ts",
5863
"import": "./dist/runtime/index.js",
@@ -396,6 +401,7 @@
396401
"@tangle-network/sandbox": ">=0.9.7",
397402
"@tangle-network/sandbox-ui": ">=0.72.0",
398403
"@xyflow/react": ">=12.0.0",
404+
"better-auth": ">=1.6.16",
399405
"drizzle-orm": ">=0.36",
400406
"konva": ">=9",
401407
"lucide-react": ">=1",
@@ -408,6 +414,9 @@
408414
"@huggingface/transformers": {
409415
"optional": true
410416
},
417+
"better-auth": {
418+
"optional": true
419+
},
411420
"@tangle-network/agent-knowledge": {
412421
"optional": true
413422
},

src/app-auth/index.ts

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
/**
2+
* better-auth config factory for agent products (#188 Phase 1). Every product
3+
* hand-rolls the same ~70–150 line setup — drizzle adapter over the standard
4+
* users/sessions/accounts/verifications tables, email+password with Resend
5+
* reset/verification mail, env-gated GitHub/Google social providers, session
6+
* cookie cache, and a per-app cookie prefix — and tax additionally
7+
* re-implemented better-auth's cookie signing for its Tangle SSO callback.
8+
* `createAppAuth` owns that mechanism once and returns the configured
9+
* better-auth instance plus the request guards products actually use.
10+
*
11+
* Domain stays a parameter: the drizzle db + schema, email client, provider
12+
* credentials, and SSO store/client all come from the product. No product
13+
* import, no engine re-implementation — the SSO path composes the existing
14+
* `platform/sso` cookie minter (`createBetterAuthSessionCookieMinter`), which
15+
* is byte-compatible with better-auth's own `makeSignature` contract.
16+
*
17+
* better-auth is an OPTIONAL peer: only this subpath imports it, so it is
18+
* deliberately NOT re-exported from the root barrel.
19+
*/
20+
21+
import { betterAuth, type Auth, type BetterAuthOptions, type Session, type User } from 'better-auth'
22+
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
23+
import { createAuthGuard, type AuthGuard } from '../platform/guards'
24+
import {
25+
createBetterAuthSessionCookieMinter,
26+
createTangleSsoHandlers,
27+
type TangleSsoAccountStore,
28+
type TangleSsoAuthClient,
29+
type TangleSsoHandlers,
30+
} from '../platform/sso'
31+
32+
const DEFAULT_STATE_COOKIE = 'tangle_sso_state'
33+
const DEFAULT_SESSION_COOKIE_CACHE_SECONDS = 5 * 60
34+
35+
/** Structural slice of a Resend-style client — no `resend` import. */
36+
export interface AppAuthEmailClient {
37+
emails: {
38+
send(message: {
39+
from: string
40+
to: string
41+
subject: string
42+
html: string
43+
text?: string
44+
}): Promise<unknown>
45+
}
46+
}
47+
48+
export interface AppAuthEmailConfig {
49+
/** A Resend-style client, or a lazy getter returning null when the API key
50+
* is absent (the products' dev default — mail is skipped with a warning,
51+
* sign-up itself must not crash). */
52+
resend: AppAuthEmailClient | (() => AppAuthEmailClient | null)
53+
/** RFC 5322 From, e.g. `'Legal Agent <noreply@legal.tangle.tools>'`. */
54+
from: string
55+
/** Send a verification email on sign-up and auto-sign-in after verifying
56+
* (the tax/gtm behavior). Default false (the legal behavior). */
57+
verifyOnSignUp?: boolean
58+
/** Receives the "email client unavailable" warning. Default console.warn. */
59+
warn?: (message: string) => void
60+
}
61+
62+
/** Env-shaped: pass the env vars straight through; the provider is registered
63+
* only when BOTH values are non-empty, so unset env disables it. */
64+
export interface AppAuthSocialProviderConfig {
65+
clientId?: string
66+
clientSecret?: string
67+
}
68+
69+
export interface AppAuthSocialConfig {
70+
github?: AppAuthSocialProviderConfig
71+
google?: AppAuthSocialProviderConfig
72+
}
73+
74+
/** Cross-site Tangle SSO wiring. The factory supplies the better-auth side —
75+
* `setSessionCookie` via `createBetterAuthSessionCookieMinter(auth)` (signed
76+
* `__Secure-`/prefixed cookie that `auth.api.getSession` accepts) — so the
77+
* product no longer touches `better-auth/crypto` itself. */
78+
export interface AppAuthSsoConfig {
79+
/** Platform wire client (authorizeUrl + exchange). */
80+
client: TangleSsoAuthClient
81+
/** Product persistence: user upsert, session row, platform link. */
82+
store: TangleSsoAccountStore
83+
/** Absolute callback URL registered with the platform. */
84+
callbackUrl: string
85+
/** Default 'tangle_sso_state'. */
86+
stateCookieName?: string
87+
/** HMAC secret for the CSRF state cookie. Default: the auth `secret`. */
88+
stateSecret?: string
89+
/** Default: `baseURL` is https. Must match better-auth's own
90+
* secure-cookie decision or the cookie name lookup diverges. */
91+
secureCookies?: boolean
92+
sessionTtlSeconds?: number
93+
stateTtlSeconds?: number
94+
/** Post-login redirect fallback. Default '/app'. */
95+
defaultRedirectPath?: string
96+
/** Default: the top-level `loginPath`. */
97+
loginPath?: string
98+
/** Failure log hook (e.g. console.error). Default no-op. */
99+
log?: (message: string, error?: unknown) => void
100+
}
101+
102+
export interface AppAuthSchema {
103+
users: unknown
104+
sessions: unknown
105+
accounts: unknown
106+
verifications: unknown
107+
}
108+
109+
export interface AppAuthConfig {
110+
/** Product name — used in email subjects and as the cookie-prefix default. */
111+
appName: string
112+
/** Absolute origin better-auth serves from (`BETTER_AUTH_URL`). */
113+
baseURL: string
114+
/** better-auth HMAC secret. Optional only because better-auth falls back to
115+
* the BETTER_AUTH_SECRET env var; `sso` needs it explicitly (or
116+
* `sso.stateSecret`). */
117+
secret?: string
118+
trustedOrigins?: string[]
119+
/**
120+
* Session-cookie prefix. Default: slugified `appName`. A per-app prefix is
121+
* load-bearing, not cosmetic: the platform (id.tangle.tools) mints its own
122+
* better-auth cookie `Domain=.tangle.tools`-wide under the DEFAULT name, and
123+
* the platform's (older) cookie wins the Cookie-header order — an app on the
124+
* default prefix reads the platform's token, fails its own signature check,
125+
* and every fresh login lands back on /login.
126+
*/
127+
cookiePrefix?: string
128+
/** Drizzle database instance; wired through better-auth's drizzle adapter
129+
* together with `schema`. */
130+
db?: unknown
131+
/** The product's users/sessions/accounts/verifications tables (mapped to
132+
* better-auth's user/session/account/verification models). */
133+
schema?: AppAuthSchema
134+
/** Drizzle dialect. Default 'sqlite' (D1). */
135+
provider?: 'sqlite' | 'pg' | 'mysql'
136+
/** Escape hatch: a pre-built better-auth database adapter (e.g.
137+
* `memoryAdapter` in tests). Wins over `db`/`schema`. */
138+
database?: BetterAuthOptions['database']
139+
/** Email+password sign-in. Default true (all current products enable it). */
140+
emailAndPassword?: boolean
141+
/** Reset/verification mail. Omit to disable password reset entirely. */
142+
email?: AppAuthEmailConfig
143+
social?: AppAuthSocialConfig
144+
/** Session cookie-cache TTL in seconds; `false` disables the cache.
145+
* Default 300. */
146+
sessionCookieCacheSeconds?: number | false
147+
/** Tangle cross-site SSO (start/callback handlers). */
148+
sso?: AppAuthSsoConfig
149+
/** Where guards redirect unauthenticated page requests. Default '/login'. */
150+
loginPath?: string
151+
/** Merged over the factory's `advanced` block (cookiePrefix stays unless
152+
* overridden here). */
153+
advanced?: BetterAuthOptions['advanced']
154+
}
155+
156+
/** The configured better-auth instance, typed at better-auth's base surface
157+
* (`handler`, `api`, `$context`, `$Infer`). */
158+
export type AppAuthInstance = Auth
159+
160+
/** What `getSession`/guards resolve: better-auth's base session + user rows. */
161+
export interface AppAuthSession {
162+
session: Session
163+
user: User
164+
}
165+
166+
export interface AppAuth extends AuthGuard<AppAuthSession> {
167+
auth: AppAuthInstance
168+
/** `auth.api.getSession` over a `Request` — the seam the guards consume. */
169+
getSession(request: Request): Promise<AppAuthSession | null>
170+
/** Tangle SSO start/callback handlers; null unless `sso` was configured. */
171+
sso: TangleSsoHandlers | null
172+
}
173+
174+
/** Lowercased, non-alphanumerics collapsed to '-': `'Legal Agent'` → `'legal-agent'`. */
175+
function slugify(name: string): string {
176+
return name
177+
.toLowerCase()
178+
.replace(/[^a-z0-9]+/g, '-')
179+
.replace(/^-+|-+$/g, '')
180+
}
181+
182+
function resolveEmailClient(email: AppAuthEmailConfig): AppAuthEmailClient | null {
183+
return typeof email.resend === 'function' ? email.resend() : email.resend
184+
}
185+
186+
/** Resend reports failures in the resolved value, not by rejecting — surface
187+
* them loud so better-auth's flow (and the caller's logs) see the failure. */
188+
async function sendEmail(
189+
client: AppAuthEmailClient,
190+
message: { from: string; to: string; subject: string; html: string; text: string },
191+
): Promise<void> {
192+
const result = (await client.emails.send(message)) as { error?: { message?: string } | null } | null | undefined
193+
if (result && typeof result === 'object' && result.error) {
194+
throw new Error(`[app-auth] email send failed: ${result.error.message ?? 'unknown error'}`)
195+
}
196+
}
197+
198+
function resolveDatabase(config: AppAuthConfig): NonNullable<BetterAuthOptions['database']> {
199+
if (config.database) return config.database
200+
if (config.db && config.schema) {
201+
return drizzleAdapter(config.db as Record<string, unknown>, {
202+
provider: config.provider ?? 'sqlite',
203+
schema: {
204+
user: config.schema.users,
205+
session: config.schema.sessions,
206+
account: config.schema.accounts,
207+
verification: config.schema.verifications,
208+
} as Record<string, unknown>,
209+
})
210+
}
211+
throw new Error(
212+
'createAppAuth requires a database: pass `db` + `schema` (drizzle) or `database` (a better-auth adapter)',
213+
)
214+
}
215+
216+
function resolveSocialProviders(social: AppAuthSocialConfig | undefined): BetterAuthOptions['socialProviders'] {
217+
const providers: NonNullable<BetterAuthOptions['socialProviders']> = {}
218+
if (social?.github?.clientId && social.github.clientSecret) {
219+
providers.github = { clientId: social.github.clientId, clientSecret: social.github.clientSecret }
220+
}
221+
if (social?.google?.clientId && social.google.clientSecret) {
222+
providers.google = { clientId: social.google.clientId, clientSecret: social.google.clientSecret }
223+
}
224+
return Object.keys(providers).length > 0 ? providers : undefined
225+
}
226+
227+
function emailAndPasswordOptions(config: AppAuthConfig): BetterAuthOptions['emailAndPassword'] {
228+
const email = config.email
229+
if (!email) return { enabled: true }
230+
const warn = email.warn ?? ((message: string) => console.warn(message))
231+
return {
232+
enabled: true,
233+
sendResetPassword: async ({ user, url }) => {
234+
const client = resolveEmailClient(email)
235+
if (!client) {
236+
warn('[app-auth] email client unavailable — password reset email not sent')
237+
return
238+
}
239+
await sendEmail(client, {
240+
from: email.from,
241+
to: user.email,
242+
subject: 'Reset your password',
243+
html: `<p>Click the link below to reset your password:</p><p><a href="${url}">${url}</a></p><p>This link expires in 1 hour.</p>`,
244+
text: `Reset your password:\n\n${url}\n\nThis link expires in 1 hour.`,
245+
})
246+
},
247+
}
248+
}
249+
250+
function emailVerificationOptions(config: AppAuthConfig): BetterAuthOptions['emailVerification'] {
251+
const email = config.email
252+
if (!email?.verifyOnSignUp) return undefined
253+
const warn = email.warn ?? ((message: string) => console.warn(message))
254+
return {
255+
sendOnSignUp: true,
256+
autoSignInAfterVerification: true,
257+
sendVerificationEmail: async ({ user, url }) => {
258+
const client = resolveEmailClient(email)
259+
if (!client) {
260+
warn('[app-auth] email client unavailable — verification email not sent')
261+
return
262+
}
263+
await sendEmail(client, {
264+
from: email.from,
265+
to: user.email,
266+
subject: `Verify your ${config.appName} email`,
267+
html: `<p>Verify your email to finish accessing ${config.appName}:</p><p><a href="${url}">${url}</a></p><p>This link expires in 1 hour.</p>`,
268+
text: `Verify your email to finish accessing ${config.appName}:\n\n${url}\n\nThis link expires in 1 hour.`,
269+
})
270+
},
271+
}
272+
}
273+
274+
/**
275+
* Build the product's better-auth instance plus the request-boundary helpers:
276+
* `getSession` (Request → session|null), the `createAuthGuard` quartet
277+
* (`requireUser` 302, `requireApiUser` JSON 401, `requireSession`,
278+
* `getOptionalSession`), and — when `sso` is configured — the Tangle SSO
279+
* start/callback handlers with the session cookie minted through better-auth's
280+
* own name/attributes/signing (no `better-auth/crypto` in product code).
281+
*/
282+
export function createAppAuth(config: AppAuthConfig): AppAuth {
283+
if (!config.appName) throw new Error('createAppAuth: appName is required')
284+
if (!config.baseURL) throw new Error('createAppAuth: baseURL is required')
285+
286+
const cookiePrefix = config.cookiePrefix ?? slugify(config.appName)
287+
if (!cookiePrefix) throw new Error('createAppAuth: cookiePrefix (or a slugifiable appName) is required')
288+
289+
const socialProviders = resolveSocialProviders(config.social)
290+
const options: BetterAuthOptions = {
291+
appName: config.appName,
292+
baseURL: config.baseURL,
293+
...(config.secret ? { secret: config.secret } : {}),
294+
...(config.trustedOrigins ? { trustedOrigins: config.trustedOrigins } : {}),
295+
database: resolveDatabase(config),
296+
...(config.emailAndPassword === false ? {} : { emailAndPassword: emailAndPasswordOptions(config) }),
297+
...(config.email?.verifyOnSignUp ? { emailVerification: emailVerificationOptions(config) } : {}),
298+
...(socialProviders ? { socialProviders } : {}),
299+
...(config.sessionCookieCacheSeconds === false
300+
? {}
301+
: {
302+
session: {
303+
cookieCache: {
304+
enabled: true,
305+
maxAge: config.sessionCookieCacheSeconds ?? DEFAULT_SESSION_COOKIE_CACHE_SECONDS,
306+
},
307+
},
308+
}),
309+
advanced: { cookiePrefix, ...config.advanced },
310+
}
311+
312+
const auth: AppAuthInstance = betterAuth(options)
313+
314+
const getSession = async (request: Request): Promise<AppAuthSession | null> => {
315+
const session = await auth.api.getSession({ headers: request.headers })
316+
return (session as AppAuthSession | null) ?? null
317+
}
318+
319+
const loginPath = config.loginPath ?? '/login'
320+
const guard = createAuthGuard<AppAuthSession>({ getSession, loginPath })
321+
322+
let sso: TangleSsoHandlers | null = null
323+
if (config.sso) {
324+
const stateSecret = config.sso.stateSecret ?? config.secret
325+
if (!stateSecret) {
326+
throw new Error(
327+
'createAppAuth: sso requires `secret` (or sso.stateSecret) — the signed-state CSRF cookie needs an HMAC secret',
328+
)
329+
}
330+
sso = createTangleSsoHandlers({
331+
auth: config.sso.client,
332+
store: config.sso.store,
333+
stateSecret,
334+
callbackUrl: config.sso.callbackUrl,
335+
stateCookieName: config.sso.stateCookieName ?? DEFAULT_STATE_COOKIE,
336+
setSessionCookie: createBetterAuthSessionCookieMinter(auth),
337+
secureCookies: config.sso.secureCookies ?? config.baseURL.startsWith('https:'),
338+
...(config.sso.sessionTtlSeconds !== undefined ? { sessionTtlSeconds: config.sso.sessionTtlSeconds } : {}),
339+
...(config.sso.stateTtlSeconds !== undefined ? { stateTtlSeconds: config.sso.stateTtlSeconds } : {}),
340+
...(config.sso.defaultRedirectPath ? { defaultRedirectPath: config.sso.defaultRedirectPath } : {}),
341+
loginPath: config.sso.loginPath ?? loginPath,
342+
...(config.sso.log ? { log: config.sso.log } : {}),
343+
})
344+
}
345+
346+
return { auth, getSession, ...guard, sso }
347+
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export * from './web/index'
2525
export * from './redact/index'
2626
export * from './assets/index'
2727
export * from './theme/index'
28+
// `/app-auth` is intentionally NOT re-exported here: it imports the optional
29+
// better-auth peer at module top (same rule as `/platform`, which stays
30+
// subpath-only for its structural seams).
2831
// `/web-react` and `/sequences-react` are intentionally NOT re-exported here:
2932
// they need the optional react peer and would drag JSX into every root-entry
3033
// consumer. `/sequences/drizzle` likewise stays subpath-only — it imports the

0 commit comments

Comments
 (0)